JWT vs Sessions
Where the source of truth for "who is this user" lives, why that decides revocation, storage and CSRF, and how to verify a JWT without shooting yourself.
A session keeps the user's state on the server and hands the browser an opaque ID to look it up with. A JWT (JSON Web Token) puts the state inside the token and signs it, so any server holding the key can trust it without a lookup. The real question is where the source of truth for "who is this and are they still allowed?" lives, and revocation, storage, size and CSRF (cross-site request forgery) exposure all follow from that one answer.
Context
HTTP does not remember you between requests. Cookies (Netscape, 1994) fixed that by letting the server hand the browser a small value it would send back every time, and the first thing every web framework did with them was store a random session ID: PHP's PHPSESSID, Java's JSESSIONID, Rails' cookie store, Express's connect.sid. The browser holds a key; the server holds the data. That model ran the web for twenty years and still runs most of it.
JSON Web Tokens came out of the JOSE (JSON Object Signing and Encryption) working group (RFC 7519, May 2015) as the token format for OAuth 2.0 and OpenID Connect: an ID token is a JWT. They spread beyond that around 2014-2016 with single-page apps and microservices, where the pitch was "stateless auth": no session store to share between services, no lookup per request, just verify a signature. Firebase Auth, Auth0, Supabase and Clerk all issue JWTs; if you have ever seen a header that starts with Authorization: Bearer eyJhbGci…, that was one.
The simplest possible example of each: a session is a Redis entry sess:8f3a… → {userId: 42} and a cookie holding 8f3a…. A JWT is the string header.payload.signature where the payload is literally {"sub":"42","exp":1790000000} in base64url, readable by anyone, forgeable by no one without the key.
- Session ID
- A random, high-entropy string with no meaning of its own; the server maps it to state.
- Claim
- A key/value in a JWT payload. Registered ones:
sub(subject),exp,iat,iss,aud,jti. - HS256 / RS256 / ES256
- Signing algorithms. HS256 is a shared secret (HMAC, a hash-based message authentication code); RS256 and ES256 are asymmetric, so verifiers only need the public key.
- Bearer
- Whoever holds the token can use it. No proof of possession, which is why storage matters so much.
- Refresh token
- A longer-lived credential exchanged for new short-lived access tokens; usually stored server-side, which quietly reintroduces state.
Why it matters
You make this decision on day one of every project and live with it for years. Get it wrong one way and you cannot log a user out after a laptop is stolen, because the token stays valid until it expires. Get it wrong the other way and you have a session store that every service must reach, becoming the thing that falls over at 3 a.m. Both are real incidents, not hypotheticals, and the fix is rarely a rewrite; it is knowing which trade you made.
JWTs also have a sharp verification surface. The alg header, key confusion between RS256 and HS256, missing audience checks and unbounded lifetimes have each produced CVEs in popular libraries and outright account takeovers in production apps.
How each one works
Anatomy of a JWT
// eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMDkifQ . eyJzdWIiOiI0MiIsInJvbGUi… . MEUCIQ…
// ^ header (base64url) ^ payload (base64url) ^ signature
// header
{ "alg": "RS256", "kid": "2026-09", "typ": "JWT" }
// payload — NOT encrypted. Anyone with the token can read this.
{
"iss": "https://auth.example.com", // who issued it
"sub": "42", // whom it is about
"aud": "orders-api", // who may accept it
"iat": 1790000000,
"exp": 1790000900, // 15 minutes later
"jti": "c1f0…" // unique id, useful for denylists
}
// signature = RS256( base64url(header) + "." + base64url(payload), privateKey )Revocation: the trade you are actually making
Deleting a session row logs the user out on their very next request. A JWT has no row to delete; the server will accept it until exp because that is the whole point of not doing a lookup. Every JWT deployment eventually needs one of three answers to "the token leaked, or the user changed their password, or we banned them".
| Strategy | How | What you give up |
|---|---|---|
| Short access + server-side refresh | Access token lives 5-15 min. A refresh token, stored and checked server-side, mints new ones. Revoke by deleting the refresh token. | A leaked access token is valid for up to 15 min. And you now have a store, i.e. a session, on the refresh path. |
| Denylist by jti | On logout/ban, write jti to Redis with a TTL (time to live) equal to the remaining lifetime. Check it on every request. | A lookup per request, which is the thing JWTs were supposed to avoid. Still cheaper than a full session load. |
| Token version on the user | Store tokenVersion in the token and on the user row. Bump the row to invalidate everything issued before. | A DB read per request unless cached. Revokes all devices at once, not one. |
export async function refresh(presented: string) {
const row = await db.refreshTokens.findByHash(sha256(presented))
if (!row) throw new Unauthorized()
if (row.usedAt) {
// A rotated-out token came back → someone has a copy. Kill the family.
await db.refreshTokens.revokeFamily(row.familyId)
throw new Unauthorized('refresh token reuse detected')
}
if (row.expiresAt < new Date()) throw new Unauthorized()
await db.refreshTokens.markUsed(row.id)
const next = randomToken()
await db.refreshTokens.insert({
hash: sha256(next), familyId: row.familyId, userId: row.userId,
expiresAt: addDays(new Date(), 30),
})
const access = await signAccessToken({sub: row.userId, exp: minutes(15)})
return {access, refresh: next}
}Storage, CSRF and XSS
Where the browser keeps the credential decides which attack you are exposed to. A cookie is sent automatically by the browser, which is why cross-site request forgery works against it, and why JavaScript cannot read it if it is HttpOnly. A header set from JavaScript is never sent automatically, so CSRF is impossible, but the script had to read the token from somewhere, and XSS (cross-site scripting) can read from the same place.
| Where | CSRF | XSS | Notes |
|---|---|---|---|
| HttpOnly cookie (session ID or JWT) | Exposed unless SameSite=Lax/Strict plus a CSRF token for cross-site POSTs | Token itself unreadable; attacker can still make requests while the page is open | The default for browsers. A JWT in an HttpOnly cookie is a perfectly good combination. |
| Memory + Authorization header | Immune | Current token stealable while the page runs | Lost on reload; needs a refresh mechanism to recover. Common for SPAs (single-page apps) talking to a separate API. |
| localStorage | Immune | Everything stealable, persistently, by any script on the origin | Do not. One compromised dependency is full account takeover. |
res.setHeader('Set-Cookie', [
`session=${value}`,
'Path=/',
'HttpOnly', // JS cannot read it
'Secure', // HTTPS only
'SameSite=Lax', // not sent on cross-site POSTs; top-level GET navigations OK
`Max-Age=${60 * 60 * 24 * 7}`,
].join('; '))
// SameSite=None (needed for third-party embeds) reopens CSRF → add a CSRF tokenVerifying a JWT without getting owned
Most JWT vulnerabilities are not in the format; they are in verifiers that trust the token to tell them how to verify it. RFC 8725 (JWT Best Current Practices) is essentially a list of these. The rule: the server decides the algorithm, the key, the issuer and the audience; the token only gets to match them.
import jwt from 'jsonwebtoken'
// 1. algorithm taken from the token's own header
// 2. no audience / issuer check
// 3. same key for HS256 and RS256 → key confusion:
// attacker signs with the PUBLIC key as an HMAC secret
const payload = jwt.verify(token, PUBLIC_KEY)
req.user = payloadimport {jwtVerify, createRemoteJWKSet} from 'jose'
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json'),
)
const {payload} = await jwtVerify(token, JWKS, {
algorithms: ['RS256'], // server picks, token must match
issuer: 'https://auth.example.com',
audience: 'orders-api',
clockTolerance: 30, // seconds of skew, not minutes
})
// exp/nbf are checked by default; kid selects the key from JWKS
req.user = {id: payload.sub}- Pin the algorithm list.
alg: noneand RS256→HS256 key confusion both rely on the verifier honouring the header. - Check
aud. A token minted for the billing API must not open the admin API just because both share an issuer. - Rotate keys with
kid+ JWKS. Publish public keys as a JWKS (JSON Web Key Set) at a well-known URL; verifiers cache them and pick by key ID, so rotation needs no deploy. - Prefer asymmetric signing across services. With HS256 every verifier holds the secret and can therefore mint tokens; with RS256/ES256 only the issuer can.
Choosing
| Situation | Pick | Because |
|---|---|---|
| Monolith or single backend serving its own browser UI | Server sessions in an HttpOnly cookie | Instant revocation, tiny cookie, no crypto to get wrong. The store is one Redis you already run. |
| Many services must verify the same identity without calling home | Short-lived JWT (RS256) + refresh token | Verification is local; only the refresh path touches state. Accept ≤15 min of staleness. |
| Third-party clients: mobile apps, partners, public API | OAuth-issued access tokens (often JWTs) | Standard bearer semantics, scopes, and the issuer handles login. See the OAuth topic. |
| You need "log out everywhere" or instant bans | Sessions, or JWT + denylist / token version | Pure stateless cannot do this. Decide up front whether that is acceptable. |
Pitfalls
- Believing "stateless" and then needing to log someone out
The first support ticket that says "my account was compromised, kill the sessions" reveals that a bare JWT cannot be revoked. Decide the revocation strategy (short expiry + refresh store, denylist, or token version) before launch, because retrofitting it means every existing token is unrevokable until it expires.
- Putting roles or permissions in the token and trusting them for a day
Demote an admin and their token still says
role: adminuntilexp. Claims are a snapshot at signing time. Keep lifetimes short if you embed authorization data, or embed only identity and look up permissions per request. - Letting the token choose the algorithm
Verifiers that read
algfrom the header acceptnone(no signature) or let an attacker re-sign an RS256 token as HS256 using the public key as the HMAC secret. Both were real library CVEs (2015, and again in several ports). Always pass an explicit algorithm allowlist. - Tokens that grow until the cookie breaks
Each claim is sent on every request. Add a display name, avatar URL, a list of team IDs and a permissions array and the token passes 4 KB, at which point browsers drop the cookie and proxies reject the header. Sessions cost 32 bytes on the wire no matter how much state they hold.
- Storing the JWT in localStorage "because cookies are old"
The reason is usually CSRF avoidance, but SameSite cookies solved most of that in 2020 while localStorage made every XSS a persistent, exfiltratable credential theft. If you must keep it in JavaScript, keep it in memory and rely on a rotated refresh token to survive reloads.
Interview questions
Q1When would you choose JWTs over server-side sessions?
When several services need to verify the caller's identity without a shared round trip: microservices, multiple regions, third-party clients. Verification is a local signature check, so there is no session store in the hot path. For a single backend serving its own UI I would default to sessions: instant revocation, a 32-byte cookie, and nothing cryptographic to misconfigure.
Q2How do you log out a user who has a JWT?
You cannot un-sign it, so you either wait for it to expire or add state. The usual design is a 5-15 minute access token plus a server-stored refresh token; logout deletes the refresh token and the access token dies on its own within minutes. For immediate effect, keep a denylist of jti values in Redis with a TTL equal to the remaining lifetime, or a per-user token version that every verifier compares against.
Q3Can a client read what is inside a JWT? Can they change it?
Read, yes: the payload is base64url, not encryption, so it is one decode away. Change, no: any edit invalidates the signature, and the verifier rejects it, as long as the verifier pins the algorithm and holds the right key. So never put secrets in the payload, but you can trust the claims after verification.
Q4What happens when a user’s role changes while they have a valid token?
Nothing, until the token expires; the role claim is a snapshot from signing time. That is acceptable if lifetimes are short and the change is a promotion. For demotions or bans I would either bump a token version stored on the user so existing tokens fail verification, or keep only identity in the token and load permissions per request from a cache.
Q5HS256 or RS256, and why does it matter across services?
RS256 or ES256 when more than one party verifies. HS256 uses one shared secret for signing and verifying, so every service that can verify a token can also forge one, and the secret has to be distributed everywhere. With an asymmetric key the issuer keeps the private key and publishes the public key via JWKS; a compromised downstream service cannot mint tokens.
Q6Walk me through implementing refresh token rotation with reuse detection.
Each refresh token belongs to a family and is stored hashed with a used-at flag. On refresh I look up the presented token; if it is already marked used, that token was rotated out and someone replayed it, so I revoke the whole family and return 401. Otherwise I mark it used, insert a new token in the same family, sign a new short-lived access token, and return both. The client keeps only the latest refresh token, and a stolen older copy triggers the family kill on first use.
Q7Where should a SPA store its token?
Ideally nowhere it can read: the SPA talks to its own backend, which holds tokens server-side and sets an HttpOnly, Secure, SameSite=Lax cookie. If the API is on another origin and a backend-for-frontend is not an option, keep the access token in memory only and rely on a rotated refresh token to survive reloads. localStorage is out because any XSS becomes persistent credential theft.
- Sessions keep state on the server and send an opaque ID; JWTs carry signed state so any key holder can verify without a lookup. Everything else follows from where the truth lives.
- A JWT cannot be revoked. Use short lifetimes plus a server-side refresh token, a jti denylist or a per-user token version, and pick one before launch.
- "Stateless" JWT auth in practice is a session with a signed cache in front: the refresh path is stateful.
- The verifier decides the algorithm, key, issuer and audience. Pin
algorithms, checkaud, rotate keys withkid+ JWKS. - Browser storage: HttpOnly + SameSite cookie by default (a JWT in a cookie is fine); memory as a fallback; never localStorage.
- Single backend serving its own UI: sessions. Many verifiers or third-party clients: short JWTs. Payload is readable, so it holds identity, not secrets.