OWASP Session Management — Condensed
    Security

    OWASP Session Management — Condensed

    The OWASP cheat sheet distilled: the eight things that actually matter and the mistakes teams make when they skip them.

    Reverse PolarityDecember 14, 20259 min read

    The OWASP Session Management Cheat Sheet runs to several thousand words across a dozen sections. Most of it you can read once and never think about again because it's covered by whatever framework you're using. What follows is the part you shouldn't delegate to defaults: the decisions that frameworks get wrong, the attributes teams omit because they seem optional, and the failure modes that show up repeatedly in security reviews.

    Diagram — Session Cookie Attributes (OWASP)
    Diagram

    1. Session ID entropy: what "sufficient randomness" actually means

    The cheat sheet puts the minimum at 64 bits of entropy, generated by a CSPRNG. That's the floor, not a target you're trying to hit exactly.

    What 64 bits buys you in practice: at 10,000 guessing attempts per second with 100,000 valid concurrent sessions, an attacker expects to spend roughly 585 years to land a valid session ID. The moment you drop to 32 bits of effective entropy, that number collapses to minutes.

    The subtle trap here is the word "effective." If your session ID is 128 characters but half of it is derived from a timestamp or a user ID, the attacker's search space is much smaller than it looks. The entire identifier needs to be unpredictable — not just the parts that look random.

    In Go, generating a session ID correctly looks like this:

    import (
        "crypto/rand"
        "encoding/base64"
        "fmt"
    )
    
    func newSessionID() (string, error) {
        b := make([]byte, 32) // 256 bits of raw entropy
        if _, err := rand.Read(b); err != nil {
            return "", fmt.Errorf("session ID generation failed: %w", err)
        }
        return base64.RawURLEncoding.EncodeToString(b), nil
    }
    

    crypto/rand wraps the OS CSPRNG (/dev/urandom on Linux, BCryptGenRandom on Windows). math/rand is not acceptable here, even seeded. The standard library makes this obvious — just don't reach for the wrong package.

    One more thing the cheat sheet flags: don't use the default cookie name from your framework. PHPSESSID, JSESSIONID, and ASP.NET_SessionId disclose your stack immediately. Rename it to something generic.


    2. Session fixation vs. session hijacking — different attacks, different defences

    These two terms get conflated. They're distinct attacks requiring distinct countermeasures.

    Session hijacking means an attacker obtains a session ID that already belongs to an authenticated user and uses it to impersonate them. The session ID leaks through network interception, XSS, logs, referrer headers, or browser history. The defences are transport security and the Secure/HttpOnly cookie attributes.

    Session fixation means an attacker forces a known session ID onto a victim before they authenticate. The classic attack: attacker obtains a pre-auth session ID (easy — most apps hand one out freely on first visit), tricks the victim into authenticating with that ID still in place, and now both parties share a session the attacker already knows. No interception required.

    The fix is simple and mandatory: regenerate the session ID immediately after any privilege elevation — authentication being the most important, but also sudo-style step-up flows, role switching, password changes, and account recovery. The old ID must be invalidated on the server.

    func loginHandler(w http.ResponseWriter, r *http.Request) {
        // ... validate credentials ...
    
        // Destroy old session, issue new one
        oldSession := getSession(r)
        if oldSession != nil {
            store.Delete(oldSession.ID)
        }
    
        newID, _ := newSessionID()
        store.Create(newID, userID)
    
        http.SetCookie(w, &http.Cookie{
            Name:     "id",
            Value:    newID,
            HttpOnly: true,
            Secure:   true,
            SameSite: http.SameSiteStrictMode,
            Path:     "/",
        })
    }
    

    Frameworks often provide a session.regenerate() method that handles this. If yours does, use it. If you're rolling your own: create new, copy necessary state, delete old — in that order.


    Every session cookie should look like this:

    Set-Cookie: __Host-id=<token>; Secure; HttpOnly; SameSite=Strict; Path=/
    

    Let's go through each attribute and why it's there.

    Secure ensures the browser only sends the cookie over HTTPS. This is not redundant with your server only listening on port 443. A network attacker can inject a plain HTTP request that tricks the browser into revealing the cookie — but only if Secure isn't set. HSTS helps, but it's not a substitute.

    HttpOnly prevents JavaScript from reading the cookie via document.cookie. It doesn't prevent CSRF (the browser still sends the cookie on cross-site requests), and it doesn't prevent an XSS attacker from making authenticated requests on the victim's behalf. What it does prevent is the attacker exfiltrating the session token for offline use. It raises the cost of XSS exploitation.

    SameSite is where teams consistently make the wrong choice. The three values behave differently:

    • SameSite=Strict — the cookie is never sent on cross-site requests, including navigations. If someone clicks a link to your app from an external site, they'll land on your app without their session cookie and will appear logged out. For most apps, this is acceptable and the right default.
    • SameSite=Lax — the cookie is sent on top-level navigations (a user clicking a link) but not on sub-resource requests from cross-site pages (fetch, img, iframe). This is the browser default in modern versions and is a reasonable middle ground when Strict would break legitimate cross-site entry points.
    • SameSite=None; Secure — the cookie is sent on all cross-site requests. Required for OAuth callback flows, embedded widgets, and third-party auth integrations. Never use this without Secure, and understand that it provides no CSRF protection.

    The __Host- prefix is underused. When you name your cookie __Host-id, the browser enforces at the client level that the cookie was set with Secure, has no Domain attribute, and uses Path=/. This prevents subdomain-based attacks where a compromised uploads.example.com tries to set a session cookie for example.com.


    4. SameSite and CSRF: the relationship is more subtle than most think

    SameSite=Strict or Lax does provide meaningful CSRF protection — a cross-site request from an attacker's page won't carry the session cookie. But it's not a complete CSRF defence, and treating it as one is the mistake.

    The gaps:

    • Lax allows cookies on safe methods (GET, HEAD, OPTIONS). If your application performs state changes via GET requests (it shouldn't, but legacy apps do), Lax doesn't protect those.
    • Subdomain-level attacks may still be possible if other subdomains are compromised and SameSite is defined as same-site against the registrable domain, not the full host.
    • Browser adoption isn't universal. Older embedded browsers, some mobile WebViews, and certain reverse proxy configurations strip or ignore SameSite.

    The correct stance: SameSite=Strict for apps where you control all entry points; keep CSRF tokens for apps with complex cross-origin interactions or sensitive state-changing operations where belt-and-braces matters. SameSite alone is not a reason to remove existing CSRF token infrastructure in a running production application.


    5. Idle timeouts and absolute timeouts: both are mandatory

    These two mechanisms are commonly conflated or treated as alternatives. They serve different purposes.

    Idle timeout expires a session after a period of inactivity. If a user walks away from a shared terminal, the session doesn't remain valid indefinitely. The window for an attacker who obtains the session ID is capped by inactivity.

    Absolute timeout expires a session regardless of activity, after a fixed maximum duration since creation. This matters because a determined attacker who hijacks a session can keep it alive by periodically sending requests — defeating the idle timeout entirely. The absolute timeout is the backstop.

    Typical values from the cheat sheet: 2–5 minutes idle for high-value applications, 15–30 minutes for low-risk. Absolute timeout is application-dependent; if users are expected to work in it all day, 4–8 hours is a reasonable ceiling.

    Critically: both timeouts must be enforced server-side. Storing the session creation time in a cookie and checking it client-side is not a timeout — it's a hint that an attacker can simply override.

    type Session struct {
        UserID    string
        CreatedAt time.Time
        LastSeen  time.Time
    }
    
    func isSessionValid(s *Session) bool {
        now := time.Now()
        idleLimit    := 15 * time.Minute
        absoluteLimit := 8 * time.Hour
    
        if now.Sub(s.LastSeen) > idleLimit {
            return false
        }
        if now.Sub(s.CreatedAt) > absoluteLimit {
            return false
        }
        return true
    }
    

    6. What proper logout actually requires

    Logout is the most consistently misimplemented part of session management. The common mistake: clearing the session cookie on the client and calling it done. That's not logout.

    From a security perspective, logout has three required parts:

    1. Invalidate the session server-side. The session record in your store must be deleted or marked invalid. If you don't do this, anyone who obtained the session ID (from a network capture, a log, a shared device) can continue using it after the user has logged out.

    2. Clear the session cookie on the client. Set the cookie to an empty value with a past Expires date. This prevents the browser from sending a stale value and confusing both the user and the server.

    3. Suppress caching. Authenticated page content cached by the browser is still accessible after logout. The Clear-Site-Data response header instructs the browser to purge cached resources, cookies, and storage associated with the origin:

    HTTP/1.1 200 OK
    Clear-Site-Data: "cache", "cookies", "storage"
    Cache-Control: no-store
    

    Send this on your logout endpoint response. Combined with Cache-Control: no-store on authenticated responses throughout the session, this closes the window where sensitive pages remain visible in the browser's back button cache.

    Missing server-side invalidation and absent Clear-Site-Data are among the most consistent findings in an authentication code review.

    The Clear-Site-Data header has good modern browser support, with the notable caveat that it only affects the current origin — not shared caches or intermediate proxies.


    7. Binding sessions to transport-layer properties

    The cheat sheet recommends optionally binding the session to the client's IP address and User-Agent string, then flagging anomalies — a sudden change in IP or user agent mid-session is a strong signal that the session has been taken over.

    This works well in corporate environments where IP addresses are stable, and it adds genuine detection value. The caveats:

    • Mobile users roam between networks. Binding too strictly to IP will generate false positives and break legitimate sessions for mobile users.
    • Large organisations route egress through shared proxies — many users legitimately share an IP.
    • User-Agent can be spoofed trivially.

    The right posture is anomaly detection rather than hard enforcement. Log the binding properties at session creation. On subsequent requests, compare and log divergences. Terminate the session if the combination is implausible (IP changed from a European geolocation to an Asian one within 30 seconds). Treat a change as suspicious, not automatically malicious.

    type SessionBinding struct {
        UserAgent     string
        IPPrefix      string // e.g. first 3 octets for some NAT tolerance
        GeoRegion     string // if you have GeoIP data
    }
    
    func validateBinding(stored, current SessionBinding) bool {
        if stored.IPPrefix != current.IPPrefix {
            log.Warn("session IP prefix change", "stored", stored.IPPrefix, "current", current.IPPrefix)
            // alert but don't necessarily terminate — depends on risk profile
        }
        if stored.UserAgent != current.UserAgent {
            log.Warn("session user agent change")
        }
        // could return false for high-security contexts
        return true
    }
    

    8. Concurrent session controls

    By default, most applications allow a user to be logged in from multiple devices simultaneously. The cheat sheet recommends making this a deliberate design decision rather than an accident of omission.

    The options:

    • Allow unrestricted concurrent sessions — simplest, correct for many consumer apps, but means a compromised credential can be used alongside the legitimate owner with no indication.
    • Terminate previous sessions on new login — the new login invalidates all existing sessions. The legitimate user will be logged out of their other devices. This is detectable (they'll be surprised to find themselves logged out) but doesn't require you to distinguish which session is "real."
    • Limit to N concurrent sessions — requires tracking active sessions per user and enforcing a cap. More complex to implement correctly but gives users meaningful control.

    Whichever policy you implement, the infrastructure requirement is the same: a server-side session store with per-user session listing. You can't enforce any of these policies if sessions are stateless JWTs with no revocation mechanism.

    The cheat sheet also recommends surfacing active session information to users — a "sessions" page showing currently active sessions with IP address, device, and last-seen time, with the ability to terminate individual sessions. This is table stakes for any application handling sensitive data.


    The world has moved toward SPAs and mobile apps, and both create pressure to abandon server-side sessions in favour of JWTs stored in localStorage. The argument usually sounds like reduced server state and simpler horizontal scaling. The security tradeoff is real: a JWT in localStorage is readable by any JavaScript executing on the page, making a single XSS vulnerability an immediate credential theft. The OWASP guidance — and the OAuth 2.0 for Browser-Based Apps draft — is unambiguous: use HttpOnly; Secure; SameSite=Strict cookies backed by a backend session store, or use the Backend-for-Frontend pattern to keep tokens out of the browser entirely. Session management hasn't become simpler in the SPA era; the surface has just shifted.


    At Reverse Polarity, we review session management implementations against OWASP ASVS V6 & V7 as part of our authentication code reviews.


    Sources

    More Articles