- Published on
JWT or Sessions, and When — Settling the Authentication Choice by Asking Where the State Lives
- Authors

- Name
- Youngju Kim
- @fjvbn20031
Introduction — what people actually mean when they say JWT scales well
There is a sentence that shows up more than any other when picking an authentication scheme. "Sessions keep state on the server so they do not scale; JWT is stateless so it scales well." That sentence is half right. And the wrong half costs far more in practice.
Start with a fact check. A JWT is not encrypted. It is merely base64url-encoded, so anyone can read it.
echo 'eyJzdWIiOiJ1c2VyXzg4MTIiLCJyb2xlIjoiYWRtaW4iLCJlbWFpbCI6ImtpbUBleGFtcGxlLmNvbSIsImV4cCI6MTc4NDA1NjAwMH0' \
| base64 -d
{"sub":"user_8812","role":"admin","email":"kim@example.com","exp":1784056000}
What the signature guarantees is that the token cannot be forged, not that it is kept secret. So putting an email address, a phone number, or an internal identifier in the payload is equivalent to storing personal data in plaintext in browser local storage. Unless you encrypt with JWE, treat the payload as public data.
This post is about the criteria for choosing between the two. To state the conclusion up front: for most web applications that talk to a browser, session cookies are the better fit, and JWT belongs in service-to-service communication and in the places where statelessness is genuinely required.
There is one fundamental difference — where the state lives
In the session approach, the server holds authentication state in a session store and the client holds only a meaningless identifier in a cookie. When a request arrives, the server looks up the store with that identifier. No result means authentication failed.
In the JWT approach, the server holds nothing and the client holds the signed claims themselves. When a request arrives, the server verifies the signature and checks the expiry. There is no store lookup.
Everything diverges from here. With sessions, the basis for the authentication decision lives on the server, so the server can change it at any time. With JWT, the basis has already left for the client, so the server cannot take it back. Performance and storage location are merely consequences of that difference.
| Aspect | Session cookie | JWT (stateless) | Short access token + refresh |
|---|---|---|---|
| State location | Server store | Client | Client + server store for refresh |
| Cost per request | 1 store lookup (under 1ms with Redis) | Signature verification (microseconds for HMAC) | Signature verification. Store lookup only per renewal |
| Instant revocation | Possible. Delete the record | Impossible. Valid until expiry | Delayed by the access token lifetime (usually 5 to 15 minutes) |
| Permission change applies | Immediately | After expiry | At renewal |
| Size | Identifier of roughly 32 bytes | 300 to 1000 bytes. Sent on every request | Same as the access token size |
| Main exposure path | CSRF | XSS (depending on where it is stored) | Depends on the storage design |
| Multi-node scaling | Needs a shared store | No shared store needed | Shared store needed only on the renewal path |
| Who can verify | The server itself | Anyone holding the secret or the public key | Anyone for the access token, only the issuer for renewal |
The last row of the table is the real reason JWT exists. A session identifier can be interpreted only by a party with access to the store, but a JWT can be verified by anyone who holds the public key. When the issuer and the verifier are different organizations or different systems, that property has no substitute.
The biggest weakness of JWT — instant revocation is impossible
Suppose an account was compromised and the user changed their password. With sessions, you delete every session record for that user and you are done. They are logged out from the very next request.
With JWT there is nothing to delete. The token the attacker holds is valid until its expiry. If you set expiry to 24 hours, the attacker stays logged in for up to 24 hours. Revoking admin rights, suspending the account, blocking payments — none of it matters. The server only verifies the signature and exp.
The realistic response is a combination of three things.
First, keep the access token lifetime short. Five to fifteen minutes is typical. This shrinks the exposure window by that much.
Second, renew with a refresh token. The refresh token is recorded in a server store and its validity is checked at renewal time. Use rotation alongside it. A refresh token that has been used once is discarded immediately and a new one issued, and if an already-used token comes back in, treat it as theft and revoke the entire family.
async function rotateRefreshToken(presented) {
const record = await db.refreshTokens.findByHash(sha256(presented))
if (!record) throw new AuthError('unknown token')
if (record.usedAt) {
// an already-used token came back = a copy is circulating
await db.refreshTokens.revokeFamily(record.familyId)
throw new AuthError('reuse detected, family revoked')
}
await db.refreshTokens.markUsed(record.id)
return issuePair({ userId: record.userId, familyId: record.familyId })
}
Third, keep a blacklist. Store the jti of every revoked token and compare against it on every request.
But there is a point here that has to be made honestly. The moment you add a blacklist, every request hits a shared store, and that is exactly the same structure as sessions. A store outage becoming an authentication outage is the same, and every node needing to connect to the store is the same. The only differences are that there are more moving parts than with sessions and the tokens are larger. It becomes a choice that removes the benefit of statelessness and keeps only the complexity.
So the genuinely usable middle ground is the combination of a short access token and a stored refresh token. It reduces store lookups from every request to once per renewal cycle, while bounding the revocation delay to within the access token lifetime. When you pick this design, the first question to answer is whether that delay is acceptable to the business. In a place like financial transaction authority, where withdrawal has to happen in seconds, it is not.
Where to store the token
This question is usually reduced to a choice between accepting XSS or accepting CSRF, but the accurate comparison is a little different.
Put it in localStorage and every script on the same origin can read it. One XSS and the whole token is shipped to the attacker server, and the attacker can then call the API freely from outside the browser. It stays valid until expiry, and the user logging out changes nothing. One XSS is credential leakage.
Put it in an httpOnly cookie and scripts cannot read the value. Even with an XSS, the token itself does not get out. The attacker script can still fire requests from inside that page and the cookie rides along, so it can act on the user's behalf. That is real damage, but the scale of it differs from a credential leaking and being used indefinitely from anywhere. The attack window is limited to that session and that page.
If you use cookies, you have to handle CSRF. Setting the attributes is the first line of defence.
Set-Cookie: sid=8f3c...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600
SameSite=Lax does not attach the cookie to a cross-site POST; it attaches it only to top-level navigation GETs. When Chromium adopted it as the default, a large share of form-based CSRF was blocked automatically. Strict also withholds the cookie on a GET arriving through an external link, which makes it look like the user has been logged out. None permits cross-site sending and requires Secure, and in that case a CSRF token is mandatory.
There is an important trap here. SameSite judges by site, not by origin. app.example.com and api.example.com are different origins but the same site. If an XSS appears on one subdomain, SameSite provides no defence at all. This is especially dangerous for services that host user content on subdomains.
The compromise widely used in SPAs is to keep the access token only in a JavaScript memory variable and to scope the refresh token as an httpOnly cookie limited to the renewal path.
// the access token lives only in a module-scope variable. a reload loses it
let accessToken = null
async function refresh() {
// the refresh cookie is scoped to Path=/auth/refresh so it rides only on this request
const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
if (!res.ok) throw new AuthError('refresh failed')
accessToken = (await res.json()).access_token
}
The cost is one renewal request on every reload; the gain is that even with an XSS, no durable credential ever reaches the script.
One thing to add: in a situation where XSS has occurred, there is no complete defence. The storage-location debate is about reducing damage, not preventing it. Content-Security-Policy and output escaping are the fundamental measures, and storage location comes after them.
Implementation flaws — a signature only means something when it is verified
The JWT spec is flexible enough that using a library slightly wrong disables the signature entirely. Here are the cases that have become classics.
There were libraries that let a token through unsigned if you changed the alg header to none. The attacker only had to change role in the payload to admin and leave the signature part empty. It sounds like ancient history, but code that reads the algorithm out of the token header at verification time still turns up today.
Algorithm confusion is subtler. If the verification function on a server that issues RS256 tokens does not pin the algorithm, the attacker changes the header to HS256 and creates a token signed with the server's public key used as the HMAC secret. The public key is, by name, public, so anyone can produce one.
The most common of all is simply not verifying.
// do not do this — decode does not verify the signature
const payload = jwt.decode(token)
if (payload.role === 'admin') grantAdmin()
// at verification, the server pins the algorithm, issuer, and audience
import { jwtVerify } from 'jose'
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ['RS256'], // the token does not get to choose
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
clockTolerance: 5,
})
If you do not verify aud, a token issued for a different service will pass at our API. When several internal services share the same authentication server, this actually happens. Not verifying iss is the same kind of problem.
When you use HMAC, a short secret can be brute-forced offline. Dictionary words and untouched default values have been found in public services. Use a random value of at least 256 bits, and keep it in a secret management system rather than in code or a repository.
An implementation that looks up keys by the kid header must not forget that the value is attacker input. Dropping it straight into a file path or a SQL lookup produces path traversal and injection. Use it only after comparing against a known set of key identifiers.
When sessions actually become a scaling problem
Let us check the received wisdom that sessions do not scale, using numbers.
A session record consists of a user identifier, an issue time, an expiry, and a little metadata. Generously, that is 200 to 500 bytes. One million concurrent sessions is 200MB to 500MB. That is a size a single Redis instance handles comfortably. Even ten million is a few gigabytes, and a service at that scale is already running a Redis cluster.
The lookup cost is an O(1) lookup on a single key. A Redis round trip within the same region is usually under 1ms, and a single instance processes tens of thousands to hundreds of thousands of commands per second. Compared with the other database queries the application runs per request, it is a negligible share.
So where do sessions actually become a problem? Three places.
When sessions live in process memory and clients are pinned with a sticky session. Every deploy logs everyone out, and load never balances evenly across nodes. That is not a problem with sessions but a problem with the choice of store, and moving to a shared store fixes it.
When there are multiple regions. If a European user's request has to round-trip to a US region for a session lookup, hundreds of milliseconds get added. You start needing per-region stores and a replication strategy. From this point, stateless tokens have a real advantage.
When the verifier is not us. A partner company has to verify our tokens, and we cannot open our session store to them. Public key verification is the answer.
In short, the point at which sessions become a scaling liability arrives much later than most teams think. Choosing JWT before that point means taking on the debt of impossible revocation first, in exchange for scalability you never actually needed.
Practical recommendations — what to use and when
For a web application running on the same domain, session cookies are the default. Attach httpOnly, Secure, and SameSite=Lax, and use Redis or a database for the store. Logout and permission revocation take effect immediately, there are fewer moving parts, and token size is a non-issue. If you need sharing across subdomains, the Domain attribute handles it.
For your own API with mobile apps or third-party clients attached, use a short access token and a rotating refresh token. Cookies are unavailable in that environment so you need tokens, and putting state on the refresh path gives you a means of revocation. Store the refresh token in the secure storage provided by the OS.
For service-to-service communication, JWT fits well. Each caller can verify with a public key instead of round-tripping to an authentication server, and if you keep the scope narrow and the lifetime short, the revocation problem effectively disappears. With a lifetime measured in tens of seconds, there is barely a reason to revoke anything.
If you use an external identity provider, the OIDC ID token is a JWT. But an ID token is meant to prove identity, not to serve as an API access credential. Using an ID token directly for API authentication is a common misuse.
Finally, there is one combination to avoid. A long-lived JWT in localStorage, with logout implemented as deleting the value on the client. The logout button revokes nothing, and a single XSS carries off a long-lived credential wholesale. It is the most widely spread tutorial shape and the most vulnerable configuration.
Closing — first check whether statelessness is free
The question to ask when choosing JWT is not "will this scale well?" but "is it acceptable that I cannot take this credential back?" If it is acceptable, you get the benefits of statelessness in full. If it is not, you will have to bring state back in some form, and at that moment most of the advantage over sessions disappears.
The first decision in authentication design is not the token format but the revocation requirement, and that requirement determines where the state goes. For an ordinary web service talking to a browser, starting with session cookies is almost always right. The cost of moving later, when statelessness genuinely becomes necessary, is cheaper than the cost of starting with JWT and living through a revocation incident.