Session cookie or JWT: choosing an authentication mechanism for the backend Intermediate
Estimated reading time: 14 min read
The problem
Section titled “The problem”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?
Quick summary
Section titled “Quick summary”| 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.
Server-side sessions done right
Section titled “Server-side sessions done right”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 |
Cookie attributes
Section titled “Cookie attributes”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.
Where to store tokens in the browser
Section titled “Where to store tokens in the browser”| 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.
JWT: what the verifier must check
Section titled “JWT: what the verifier must check”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:
- Allowlist
alg(fixed in code, never read from the token to decide). - Choose the key by
kidfrom the expected issuer’s key set (JWKS fetched from the configuredjwks_uri); never use a key embedded in the token. - Verify the signature. If it fails, stop; don’t read the claims.
- Check
iss,aud,exp,nbf(andtypif used). RFC 7519 allows a small leeway for clock skew, “usually no more than a few minutes”. - Only then authorization: does
subhave 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.
Don’t put secrets in claims
Section titled “Don’t put secrets in claims”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.
Key rotation with kid and JWKS
Section titled “Key rotation with kid and JWKS”The issuer publishes public keys as a JWK Set, each key with a kid. A safe key rotation procedure:
- Add the new key to the JWKS, not yet used for signing.
- Wait for verifiers to refresh their JWKS cache.
- Switch to signing with the new key.
- 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.
Demo: HS256 with node:crypto
Section titled “Demo: HS256 with node:crypto”No libraries, no external services. The clock is fixed at now = 1800000000, the access token lives 300 seconds, leeway is 30 seconds.
npx --prefix examples ts-node examples/src/session-vs-jwt/jwt-demo.tsReal 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-422. payload sub -> admin REJECT bad signature3. alg: none, empty signature REJECT alg "none" not allowed4. header alg: HS512 REJECT alg "HS512" not allowed5. same token, 301s later ACCEPT sub=user-426. same token, 331s later REJECT expired (exp)7. aud = billing-api REJECT wrong audReading the output:
- Line 2: modifying the payload immediately breaks the signature. The signature protects integrity, not secrecy (the
payloadline 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: nonevulnerabilities 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.
Revocation: JWT needs state again
Section titled “Revocation: JWT needs state again”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.
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:
npx --prefix examples ts-node examples/src/session-vs-jwt/refresh-rotation-demo.tst1 user refreshes RT1 OK, new RT eQ4inm...t2 attacker replays RT1 FAIL REUSE detected -> family a57968e5 revokedt3 user refreshes RT2 FAIL family revokedfamily status: revoked, revokedRFC 9700 also allows revoking RTs on password change or logout, and recommends expiring RTs when the client has been inactive for a while.
What logout really means
Section titled “What logout really means”| 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.
When to use sessions, when to use JWT
Section titled “When to use sessions, when to use JWT”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.
Real-world example
Section titled “Real-world example”Trade-offs
Section titled “Trade-offs”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
expwithout 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.
Production checklist
Section titled “Production checklist”- Session IDs have ≥ 64 bits of entropy from a CSPRNG; regenerate after login and every privilege change.
- Cookies:
__Host-prefix,Secure,HttpOnly, explicitSameSite; plus CSRF protection (CORS and CSRF). - Don’t store session IDs, JWTs or refresh tokens in
localStorage/sessionStorage. - JWT:
algallowlist in code, one algorithm per key, validateiss/aud/exp/nbf, small leeway; keys come from the configured issuer’s JWKS. - HS256 secrets ≥ 256 random bits; with multiple verifiers, use ES256/RS256.
- Short-lived access tokens (minutes); refresh token rotation with reuse detection, stored as hashes, expiring on inactivity.
- Logout and account locking invalidate on the server: delete sessions, revoke RT families, bump the session version.
- Don’t put sensitive data in claims; don’t log whole tokens.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”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?