Tag: SameSite

  • Session management: cookies, SameSite, revocation, and where JWTs go wrong

    Session management: cookies, SameSite, revocation, and where JWTs go wrong

    Part 4 of 4 in the Authentication done right series. Part 1: Password storage in 2026. Part 2: MFA that survives phishing. Part 3: OAuth 2.0 vs OpenID Connect.

    This series started with storing passwords, moved through MFA, then OAuth and OpenID Connect. All of that work produces one thing at the end: a session. From then on, every request is authenticated by whatever token the browser sends, and the password and the security key are no longer involved. If the session is weak, everything before it was ceremony.

    What a session token needs to be

    Bar chart: a server-side session stops working immediately on logout, a short JWT with rotating refresh tokens works up to 10 minutes, a 15 minute JWT up to 15 minutes, and a 24 hour JWT without a denylist up to 1,440 minutes

    Whatever form it takes, a session token has to be unguessable, bound to one login, and revocable. For a classic server side session that means a random identifier with at least 128 bits from a CSPRNG, stored in a server side table or cache, with all the state kept on the server. OWASP’s floor is 64 bits of entropy. I don’t see a reason to stop there when 128 costs nothing.

    Two lifecycle rules catch a lot of bugs. Issue a new session identifier on every privilege change, especially login, or you’re open to session fixation, where an attacker plants a known session ID in the victim’s browser before they log in and then shares the authenticated session. And enforce both an idle timeout and an absolute lifetime, so a session that’s been stolen doesn’t stay useful for months just because something keeps it busy.

    Cookie attributes that matter

    The browser is where sessions get stolen, and cookie attributes are how you tell the browser to protect them. A good default looks like this:

    Set-Cookie: __Host-session=Xq3...; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=28800

    Secure keeps the cookie off plaintext HTTP. HttpOnly stops JavaScript from reading it, which won’t prevent an XSS payload from making requests as the user but does stop it from shipping the token off to be used from elsewhere, hours later. The __Host- prefix is underused. The browser only accepts a cookie with that name if it has Secure, Path=/ and no Domain attribute, which means a sibling subdomain can’t overwrite or shadow it. That closes off a whole family of cookie tossing attacks for free.

    What SameSite does and doesn’t do

    SameSite controls whether the browser attaches a cookie to requests that start on another site. Strict never sends it cross site, including when a user follows a link to your app from an email, which makes them look logged out. Lax sends it on top level GET navigations but not on cross site POSTs, iframes or subresource requests. None always sends it and requires Secure. Chrome began treating cookies with no SameSite attribute as Lax in 2020, and other browsers have moved in similar directions, though not identically.

    That made classic CSRF much harder, but I wouldn’t remove CSRF tokens because of it. Three reasons. Lax still sends cookies on top level GETs, so any state changing GET endpoint is still exposed. Chrome’s default-Lax behaviour includes a two minute window in which a freshly set cookie without an explicit attribute is still sent on cross site top level POSTs, to avoid breaking some login flows. And “site” means registrable domain, not origin. evil.example.com and app.example.com are the same site, so a subdomain takeover or an XSS on a marketing subdomain bypasses SameSite completely. Keep the anti-CSRF token or check the Origin header on state changing requests, and treat SameSite as defence in depth.

    Revocation is the real requirement

    Logging out has to end the session on the server. A logout that only deletes the cookie leaves the token valid for whoever copied it. Users also need “sign out of all devices”, support staff need to kill sessions for a compromised account, and a password change or MFA reset should invalidate every other session. With server side sessions all of that is a delete query.

    This is where JWTs get awkward. A signed, self contained token is valid until its exp unless the server checks something on every request. The usual answer is short lived access tokens (5 to 15 minutes) plus a refresh token that the server does track. RFC 9700 describes refresh token rotation: each refresh returns a new refresh token and invalidates the old one, and if an old one is ever presented again you assume theft and revoke the whole family.

    def refresh(presented_token):
        rec = db.refresh_tokens.find(hash(presented_token))
        if rec is None:
            raise Unauthorized()
        if rec.used:
            # a rotated token came back: someone has a copy
            db.refresh_tokens.revoke_family(rec.family_id)
            alert("refresh token reuse", rec.user_id)
            raise Unauthorized()
        rec.used = True
        new_rt = issue_refresh_token(rec.user_id, family_id=rec.family_id)
        return issue_access_token(rec.user_id, ttl=600), new_rt

    Notice that this is a database lookup on every refresh. Once you need revocation, and you will, you have server side state again. That’s fine. Just be honest that a JWT session isn’t really stateless.

    Where JWTs go wrong

    Most JWT vulnerabilities come from verification code trusting the token’s own header. The alg field says how the token claims to be signed, and early libraries used it to choose the verification method. That allowed alg: none tokens with no signature at all, and algorithm confusion, where a token signed with HS256 using the server’s RSA public key as the HMAC secret passed verification. CVE-2015-9235 in Node’s jsonwebtoken is the well known instance of the latter. The fix is to pin the expected algorithms on the server and never read them from the token:

    claims = jwt.decode(
        token,
        public_key,
        algorithms=["ES256"],
        audience="https://api.example.com",
        issuer="https://auth.example.com",
    )

    Library bugs happen even when you do this. CVE-2022-21449, nicknamed Psychic Signatures, made Java 15 through 18 accept ECDSA signatures with both values set to zero as valid for any message, which broke ES256 JWT verification on those runtimes until the April 2022 patch. Keep the JWT library and the runtime patched, and test that a garbage signature is rejected.

    RFC 8725, the JWT Best Current Practices, covers the rest: validate iss and aud, use explicit typing so one kind of token can’t be replayed as another, and don’t put secrets in the payload, since a signed JWT is encoded, not encrypted.

    Storage is the last trap. Single page apps often put tokens in localStorage, where any XSS can read them and send them elsewhere. A backend-for-frontend that holds the tokens server side and gives the browser an HttpOnly, __Host- cookie avoids that, and it fits the OAuth guidance from the previous part of this series.

    What I’d use

    For a typical web app with its own backend, I’d use opaque server side sessions in a hardened cookie. They’re simpler, revocation is trivial, and nothing about them is clever. I reach for JWTs when a token really has to be verified by services that can’t share a session store, and then I keep them short lived, pin the algorithm, and pair them with rotating refresh tokens. Choosing JWTs because they sound modern is how teams end up rebuilding server side sessions badly, one denylist at a time.