Skip to content
Knowledge Hub

Session cookie or JWT: choosing an authentication mechanism for the backend Intermediate

Estimated reading time: 14 min read

A shop has a web app and a mobile app. The new team chooses JWT “because it’s stateless and easy to scale”, and stores a 7-day access token in localStorage.

Three months later, an employee leaves and their account is locked, but their token can still call the API for another 7 days. There is no way to revoke it, because the server stores nothing.

The right question is not “which is more modern, session or JWT”, but: where does login state live, and how quickly do you need to revoke it?

Criterion Server-side session (cookie holding an ID) Self-contained JWT
What the client holds A random, meaningless ID Signed claims (sub, exp, aud…)
What the server holds A session record (DB, Redis) Only the key to verify
Revocation Delete the record → effective on the next request Cannot be revoked before exp unless you add state (denylist, session version)
Size A few dozen bytes A few hundred bytes or more; the demo below: 263 bytes with 7 claims
Per request One lookup in the session store Local signature verification, no I/O
Horizontal scaling Needs a shared store (or sticky sessions) Any instance with the key can verify
Content readable? No, it’s just an ID Yes: the payload is just base64url, not encrypted
XSS exposure Low if the cookie is HttpOnly High if stored in localStorage
CSRF exposure Yes (the browser attaches cookies automatically) No if sent via the Authorization header; yes if placed in a cookie

Two conclusions:

  • For your own web app (same site), a session cookie is the simpler and safer default.

    OWASP notes that if you use JWT as a session, you must have a way to invalidate it. Once you need a denylist or a lookup, JWT is no longer “stateless”, so for a web app with one backend, sessions are usually simpler.

  • JWT makes sense when multiple services/third parties need to verify without calling back to the auth server: OAuth access tokens, service-to-service tokens, APIs for mobile.

CSRF and SameSite are analyzed separately in CORS and CSRF; this article only mentions them where a comparison needs it.

A session ID is just a pointer to data on the server. All the security lies in the ID and the cookie holding it.

Requirement (OWASP Session Management) Specifics
ID entropy At least 64 bits, generated with a CSPRNG. In practice: randomBytes(32) for headroom
Change the ID after login Must regenerate after every privilege change (login, elevation) to block session fixation
Timeouts decided by the server Idle: 2–5 minutes for high-value apps, 15–30 minutes for low-risk apps. Absolute: 4–8 hours for apps used throughout the workday
Logout Must invalidate on the server; just deleting the cookie on the client is not enough
Set-Cookie: __Host-sid=k3J9...; Path=/; Secure; HttpOnly; SameSite=Lax
Attribute Effect Cannot do
HttpOnly JavaScript cannot read it via document.cookie Does not stop the browser from attaching the cookie to requests (including script-initiated fetch), so it doesn’t prevent CSRF
Secure Only sent over https: (except localhost) Does not encrypt the cookie’s content
SameSite=Strict Cookie not sent with cross-site requests Users arriving from an external link appear logged out
SameSite=Lax Also sent with top-level navigations using a safe method (GET) Not a complete CSRF defense layer
__Host- prefix The browser only accepts the cookie if it has Secure, no Domain, and Path=/ Does not replace HttpOnly or SameSite

OWASP requires declaring SameSite explicitly (Strict preferred, or Lax) and recommends the __Host- prefix for session IDs. __Host- locks the cookie to the exact host: other subdomains cannot overwrite it. SameSite and cookie prefixes are defined in the RFC 6265bis update (still an Internet-Draft), not in the original RFC 6265.

Storage Can XSS read the token? CSRF? Verdict
localStorage / sessionStorage Yes, any script on the same origin No OWASP: do not store session IDs, JWTs or refresh tokens here
A variable in the SPA’s memory Yes (scripts running in the same context) No Lost on reload; still exposed to XSS
HttpOnly; Secure; SameSite cookie Cannot be read Yes, needs CSRF protection Recommended by OWASP (SameSite=Strict preferred)
A BFF (backend-for-frontend) holds the token No, the browser only has a session cookie Yes, like a session Best for SPAs calling APIs with OAuth

The limit must be stated clearly: HttpOnly only prevents theft of the token. If the page already has XSS, the malicious script can still send requests on the user’s behalf right in that browser.

But it cannot carry the token away for use elsewhere, and the token is not exposed after the vulnerability is patched.

A JWT signed with HS256 = base64url(header).base64url(payload).HMAC-SHA256(...). The header states the algorithm (alg), but the header is attacker-controlled data. RFC 8725 summarizes the bugs seen in the wild:

Bug Mechanism How to block it (RFC 8725)
alg: none The attacker changes alg to none and drops the signature; old libraries accept it §3.1: the caller specifies an algorithm allowlist; the library must not use algorithms outside it
Algorithm/key confusion The token changes RS256 → HS256; the library uses the RSA public key as the HMAC secret, and everyone has the public key §3.1: each key is used with exactly one algorithm
Weak keys The HMAC secret is a memorable password → offline brute force once you have one token §3.5: keys with enough entropy. OWASP: HS256 secrets of at least 256 bits, randomly generated
Substitution A token issued for service A is used at service B with the same issuer §3.8–3.9: validate iss; if the issuer serves multiple parties, aud is mandatory and must be validated
Trusting kid, jku in the header kid goes into a query (SQL injection), jku points to the attacker’s URL (SSRF, fake keys) §3.10: sanitize kid; only take keys from preconfigured trusted sources
Cross-JWT confusion An ID token or password reset token is used as an access token §3.11–3.12: explicit typ (e.g. at+jwt), separate validation rules

The correct verification order:

  1. Allowlist alg (fixed in code, never read from the token to decide).
  2. Choose the key by kid from the expected issuer’s key set (JWKS fetched from the configured jwks_uri); never use a key embedded in the token.
  3. Verify the signature. If it fails, stop; don’t read the claims.
  4. Check iss, aud, exp, nbf (and typ if used). RFC 7519 allows a small leeway for clock skew, “usually no more than a few minutes”.
  5. Only then authorization: does sub have rights to this object (BOLA/IDOR).

In production, use a maintained library (for example jose for Node) and pass algorithms, issuer and audience explicitly. The hand-written demo below exists only to show each step.

A JWS payload is just base64url. Anyone with the token (logs, proxies, browser extensions) can read it. RFC 7519 §12: the simplest way to reduce privacy risk is to not put sensitive information in the JWT.

If it must be hidden, keep the data on the server, or use JWE (encryption), but that adds complexity.

The issuer publishes public keys as a JWK Set, each key with a kid. A safe key rotation procedure:

  1. Add the new key to the JWKS, not yet used for signing.
  2. Wait for verifiers to refresh their JWKS cache.
  3. Switch to signing with the new key.
  4. Keep the old key in the JWKS for at least the maximum lifetime of tokens signed with it, then remove it.

With HS256 there is no public key: every verifier holds the same secret, so anyone who can verify can also sign. Once two or more services need to verify, switch to an asymmetric algorithm (ES256, RS256) so only the auth server holds the private key.

No libraries, no external services. The clock is fixed at now = 1800000000, the access token lives 300 seconds, leeway is 30 seconds.

Terminal window
npx --prefix examples ts-node examples/src/session-vs-jwt/jwt-demo.ts

Real output (the beginning of the token changes on every run because the secret is random):

token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ... (263 bytes)
payload (base64url, NOT encrypted) = {"iss":"https://auth.shop.example","aud":"orders-api","sub":"user-42","jti":"a1b2c3","iat":1800000000,"nbf":1800000000,"exp":1800000300}
1. valid token ACCEPT sub=user-42
2. payload sub -> admin REJECT bad signature
3. alg: none, empty signature REJECT alg "none" not allowed
4. header alg: HS512 REJECT alg "HS512" not allowed
5. same token, 301s later ACCEPT sub=user-42
6. same token, 331s later REJECT expired (exp)
7. aud = billing-api REJECT wrong aud

Reading the output:

  • Line 2: modifying the payload immediately breaks the signature. The signature protects integrity, not secrecy (the payload line is printed in the clear).
  • Lines 3–4: blocked at the allowlist step, before the signature is even touched. This is where the classic alg: none vulnerabilities happen.
  • Lines 5–6: expired by 1 second but still accepted because of the 30-second leeway. Leeway is real extra lifetime; keep it small.
  • Line 7: a valid signature from the same issuer, but the wrong audience → rejected. This is a substitution attack.

No strategy gives both instant revocation and complete statelessness. Choose an acceptable level of state:

Strategy How How fast revocation takes effect Cost
Short-lived access tokens exp of a few minutes Delayed by at most the TTL Requires refresh tokens
Refresh token rotation + reuse detection Every refresh issues a new RT and invalidates the old one; reuse of an old RT → revoke the whole family At the next refresh The auth server stores RT state; races when two tabs refresh at once
Denylist by jti Store revoked (iss, jti) until exp Immediately A lookup per request, like a session
Session version The token carries sv; the user has a session_version in DB/cache; bump it to “log out of all devices” Immediately A lookup per request (cacheable)

OWASP warns: key the denylist by jti + iss, not by a hash of the whole token. A token hash can be evaded through JWT malleability (lax parsing, malleable ECDSA signatures).

Refresh token rotation and reuse detection

Section titled “Refresh token rotation and reuse detection”

RFC 9700 §4.14.2 requires the auth server to detect refresh token replay for public clients (SPA, mobile) via sender-constrained tokens or rotation. When an old RT is reused, the server cannot tell who the attacker is, so it revokes the active RT and forces a new login.

A stolen refresh token: rotation detects reuse and revokes the whole family
A stolen refresh token: rotation detects reuse and revokes the whole familyStep 1/6: The user logs in and receives a short-lived access token (AT) and refresh token RT1. The server stores RT1's hash and ties it to family F (one login).timefamily F still valid …Logingets AT + RT1RT1 = activefamily FReal clientuser-42's appAuth serverstores RT hashes by familyAttackerhas a copy of RT1
Step 1/6

The user logs in and receives a short-lived access token (AT) and refresh token RT1. The server stores RT1's hash and ties it to family F (one login).

An in-memory simulation that stores the SHA-256 of each RT instead of the RT itself:

Terminal window
npx --prefix examples ts-node examples/src/session-vs-jwt/refresh-rotation-demo.ts
t1 user refreshes RT1 OK, new RT eQ4inm...
t2 attacker replays RT1 FAIL REUSE detected -> family a57968e5 revoked
t3 user refreshes RT2 FAIL family revoked
family status: revoked, revoked

RFC 9700 also allows revoking RTs on password change or logout, and recommends expiring RTs when the client has been inactive for a while.

Action Session cookie JWT + refresh token
Log out this device Delete the session record + Set-Cookie with Max-Age=0 Revoke the RT family; the access token stays alive until exp unless there is a denylist
Log out all devices Delete all of the user’s sessions Revoke all families, or bump the session version
Lock the account, change password Same as above Same as above; the access token TTL is the maximum delay

If the product requires “locked means access is gone immediately”, plain JWT cannot deliver it. Either accept a delay equal to the TTL, or add a lookup.

Loading diagram…

Situation Choose Reason
Server-rendered web app or same-site SPA Session cookie Instant revocation, no token exposed to JS, fewer things to get wrong
SPA using OAuth/OIDC with an external IdP BFF + session cookie Tokens stay on the server; follows OWASP’s guidance to avoid storing tokens in the browser
Mobile app calling an API Short-lived access token + RT rotation No browser-style cookie jar; store the RT in the OS’s secure storage
Service-to-service, many consumers JWT with asymmetric signing, a separate aud per service Local verification, no calls back to the auth server
“Locked means access is gone immediately” is required Session, or JWT + lookup (denylist/version) Plain JWT only becomes invalid at exp

Don’t use JWT just to “avoid needing Redis”. One Redis lookup per request is usually far cheaper than the consequences of tokens you cannot revoke.

What self-contained JWT gains

  • Local verification, no I/O, no dependency on a session store.
  • Many services and organizations can trust the same issuer via JWKS.
  • Carries claims (scope, tenant) along with the request.

What self-contained JWT loses

  • Cannot be revoked before exp without adding state.
  • Many ways to validate incorrectly (alg, aud, kid), each one a vulnerability.
  • The payload is readable; tokens are many times larger than a session ID.
  • Key rotation and JWKS management are extra operational work.
  1. Session IDs have ≥ 64 bits of entropy from a CSPRNG; regenerate after login and every privilege change.
  2. Cookies: __Host- prefix, Secure, HttpOnly, explicit SameSite; plus CSRF protection (CORS and CSRF).
  3. Don’t store session IDs, JWTs or refresh tokens in localStorage/sessionStorage.
  4. JWT: alg allowlist in code, one algorithm per key, validate iss/aud/exp/nbf, small leeway; keys come from the configured issuer’s JWKS.
  5. HS256 secrets ≥ 256 random bits; with multiple verifiers, use ES256/RS256.
  6. Short-lived access tokens (minutes); refresh token rotation with reuse detection, stored as hashes, expiring on inactivity.
  7. Logout and account locking invalidate on the server: delete sessions, revoke RT families, bump the session version.
  8. Don’t put sensitive data in claims; don’t log whole tokens.

Self-check: (1) Why does HttpOnly not prevent CSRF? (2) If a token has alg: HS256 while the server uses RS256, what happens with a loosely configured library? (3) Once refresh token rotation detects reuse, which tokens are revoked, and why not just the old one?