Few security debates generate as much heat as JWTs against sessions, and most of it comes from comparing things that are not alternatives. A session token and a JWT differ in one fundamental way: a session token is a reference to state held on the server, while a JWT carries its state inside itself, signed so the server can trust it without looking anything up. Everything else people argue about, cookies against headers, stateless against stateful, scalability, follows from that difference or is a separate decision entirely.
This article works from that distinction to a decision. It shows both mechanisms in code, measures their size, explains why revocation is the real dividing line, separates the token format from where a browser keeps it, and ends with the hybrid architecture most production systems converge on. How to validate a JWT safely is covered in JWT validation, and the lifecycle of server sessions in session management; here the question is which to use where.
Two questions people conflate
Question one is the token format. A reference token is a random string with no meaning; the server looks it up to find out who you are. A self-contained token, almost always a JWT (RFC 7519) signed as a JWS, contains claims such as subject, expiry and scopes, plus a signature the server checks with a key.
Question two is the transport: how the client presents the token. Browsers can send it automatically in a cookie, or JavaScript can attach it in an Authorization header. Either format can travel either way. A JWT in an HttpOnly cookie is perfectly reasonable, and so is an opaque token in a Bearer header, which is how many OAuth providers issue access tokens. Keeping the two questions apart prevents most bad decisions, such as moving to JWTs to avoid cookies, or storing a JWT in localStorage because JWTs are supposed to be stateless.
Both mechanisms from first principles
An opaque session token needs only a good random generator and a store. Generate at least 128 bits of randomness, give the raw value to the client, and store a hash of it as the key, so a leaked database dump does not contain usable tokens.
import hashlib, json, secrets, time
import redis
r = redis.Redis()
SESSION_TTL = 8 * 3600
def create_session(user_id, roles):
token = secrets.token_urlsafe(32) # 256 bits, 43 characters
key = "sess:" + hashlib.sha256(token.encode()).hexdigest()
r.set(key, json.dumps({"uid": user_id, "roles": roles, "iat": int(time.time())}),
ex=SESSION_TTL)
return token # goes into the cookie
def load_session(token):
raw = r.get("sess:" + hashlib.sha256(token.encode()).hexdigest())
return json.loads(raw) if raw else None # None means logged out or expired
def revoke_session(token):
r.delete("sess:" + hashlib.sha256(token.encode()).hexdigest())A JWT needs a key pair and a library. The issuer signs with a private key; every service verifies with the public key, usually fetched from a JWKS endpoint and selected by the kid header. With PyJWT the core is two calls. Pin the algorithm list, and check issuer and audience every time.
import time, uuid
import jwt # PyJWT
def issue_access_token(private_key, user_id, scope):
now = int(time.time())
claims = {"iss": "https://auth.example.com", "sub": user_id,
"aud": "https://api.example.com", "iat": now, "exp": now + 900,
"jti": str(uuid.uuid4()), "scope": scope}
return jwt.encode(claims, private_key, algorithm="RS256",
headers={"kid": "2026-09-a", "typ": "at+jwt"})
def verify_access_token(public_key, token):
return jwt.decode(token, public_key, algorithms=["RS256"], # never take alg from the token
audience="https://api.example.com",
issuer="https://auth.example.com",
options={"require": ["exp", "iat", "sub"]}, leeway=30)The asymmetry is visible already. The session version is mostly storage; the JWT version is mostly cryptography and claim checking. The typ value at+jwt comes from RFC 9068, the JWT profile for OAuth access tokens, and helps a service refuse an ID token presented as an access token.
Worked example: size and per-request cost
Size matters because tokens travel on every request and cookies have practical limits. Take a realistic access token: an RS256 header with kid and typ, and a payload with issuer, subject, audience, issue and expiry times, a UUID jti, client id, three scopes, two roles and a tenant. Encoding it compactly and measuring gives these numbers:
| Token | Parts | Encoded length |
|---|---|---|
| Opaque session id | 32 random bytes, base64url | 43 characters |
| JWT, RS256 (2048-bit key) | 64 header + 367 payload + 342 signature, two dots | 775 characters |
| Same JWT, ES256 | 64 + 367 + 86 signature, two dots | 519 characters |
The JWT is twelve to eighteen times larger before anyone adds more claims, and teams do add claims: permissions lists, feature flags, organisation trees. RFC 6265 only asks browsers to support at least 4,096 bytes per cookie, and proxies and servers cap total header size, so a token that grows with a user's permissions eventually breaks requests for your most privileged users first.
Per-request cost runs the other way. The opaque token needs a store round trip, typically well under a millisecond on a local Redis but a network hop nonetheless, and the store becomes a dependency of every request. The JWT needs a signature verification, which is CPU only, plus a cached key. At very high request rates across many services, removing a shared store from the hot path is the genuine scalability argument for JWTs. For a single application talking to its own database, it rarely matters.
The real dividing line: revocation
Log out, password change, account suspension, a stolen laptop, a role removed from an employee: all of them require that a credential stops working now. With an opaque token, you delete the server record and the very next request fails. With a JWT, the token is valid until its exp claim, because nothing is consulted. Every way of fixing that reintroduces state, so the honest question is how much state and where.
- Short lifetimes. Make access tokens live for five to fifteen minutes and accept that revocation takes up to that long. This is the baseline and costs nothing per request.
- A denylist of jti values. On revocation, store the token's jti with a TTL equal to its remaining lifetime. Services check the list on each request. Because entries expire with the tokens, the list stays small.
- A per-user token version. Put a version number in the token and keep the current version per user in a store; bump it to invalidate every token that user holds at once. Useful for password changes and suspensions.
- Introspection. Ask the issuer on each request whether the token is active (RFC 7662). This is a session lookup by another name, and it is the right choice for high-value operations.
def revoke_jwt(claims):
remaining = claims["exp"] - int(time.time())
if remaining > 0:
r.set("deny:" + claims["jti"], 1, ex=remaining)
def is_revoked(claims):
return r.exists("deny:" + claims["jti"]) == 1Once you run a denylist check on every request, you have a store on the hot path again, just a smaller one. That is often fine: a denylist that is nearly always empty can be replicated to each service and cached. Recognising that you have made this trade is what matters.
Where the browser keeps the token
For browser applications, storage location decides your exposure more than the format does. Anything JavaScript can read, including localStorage, sessionStorage and in-memory variables, can be read by any script that runs on your origin, so a single cross-site scripting bug hands the attacker a bearer token they can use from anywhere until it expires. An HttpOnly cookie cannot be read by script. An XSS attacker can still make requests from the victim's browser while the page is open, but cannot carry the token away.
Cookies bring their own obligation: because the browser attaches them automatically, you must defend against cross-site request forgery. The SameSite attribute, now specified in the RFC 6265bis draft rather than the original RFC 6265, blocks most cross-site sends when set to Lax or Strict; do not rely on browser defaults, set it explicitly, and keep a token-based defence for state-changing requests as described in CSRF defence. A strong cookie for a session looks like this:
Set-Cookie: __Host-sid=Jq3...43 chars...; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=28800The __Host- prefix makes the browser reject the cookie unless it is Secure, has Path=/ and has no Domain attribute, which stops a sibling subdomain from overwriting it. Whatever you choose, a strict Content Security Policy reduces the XSS risk that drives this whole decision.
The hybrid most systems converge on
Mature systems rarely pick one. The common shape uses JWTs where their strengths matter, short-lived access tokens verified by many services, and server-side state where revocation matters, refresh tokens and browser sessions.
For a single-page application, the backend-for-frontend pattern, described in RFC 10017 (OAuth 2.0 for Browser-Based Applications, published in 2026), keeps all tokens out of the browser. The SPA talks to its own backend with an HttpOnly session cookie; the backend holds the access and refresh tokens obtained through an authorization code flow with PKCE and attaches the access token when calling APIs. The browser never sees a bearer token, so XSS cannot exfiltrate one.
Refresh tokens, which live much longer, should be opaque, stored server-side, and rotated on every use. Rotation with reuse detection, recommended for public clients in the OAuth 2.0 Security Best Current Practice (RFC 9700), turns theft into a detectable event: if an old refresh token is presented after it was rotated, either the attacker or the user is holding a copy, so the whole family is revoked.
def refresh(presented):
rec = r.hgetall("rt:" + sha(presented))
if not rec:
raise Unauthorized()
family = rec[b"family"].decode()
if rec[b"used"] == b"1": # replay of a rotated token
revoke_family(family) # kill every token in the chain
raise Unauthorized("refresh token reuse")
r.hset("rt:" + sha(presented), "used", 1) # keep briefly for reuse detection
new_rt = secrets.token_urlsafe(32)
r.hset("rt:" + sha(new_rt), mapping={"family": family, "uid": rec[b"uid"], "used": 0})
r.expire("rt:" + sha(new_rt), 30 * 86400)
r.sadd("family:" + family, sha(new_rt))
return issue_access_token(PRIVATE_KEY, rec[b"uid"].decode(), "orders:read"), new_rtIn a real system the check-and-mark step must be atomic, for example a Lua script or a database transaction, so two concurrent refreshes cannot both succeed. Allow a grace window of a few seconds for the legitimate race where two browser tabs refresh at once, or route all refreshes through one tab.
Microservices: translate at the edge
Inside a service mesh, the arguments flip. Internal services should not each call a session store, and they benefit from a signed, short-lived statement of who the caller is. A common data flow: the browser presents an opaque session cookie to the gateway; the gateway validates the session, mints a JWT with a lifetime of a minute or two and an audience naming the internal service, and forwards the request with that JWT. Downstream services verify locally. OAuth 2.0 Token Exchange (RFC 8693) standardises the same idea between services. Revocation stays at the edge, where the session lives, and the internal tokens expire too quickly to matter.
Failure modes
- Algorithm confusion and unsigned tokens. Libraries that take the algorithm from the token header have accepted alg none or verified an RS256 token with HMAC using the public key as the secret. Pin algorithms; RFC 8725 lists the rest.
- Stale claims. Roles inside a JWT stay in force until expiry after you remove them. Keep authorisation-critical claims out of long-lived tokens, or check them against a store.
- Token bloat. Permissions embedded in claims push headers past proxy limits, and the failure lands on administrators first.
- Clock skew. Services with drifting clocks reject fresh tokens or accept expired ones. Allow small leeway, around thirty seconds, and run time sync.
- Key rotation. Publishing a new key and signing with it in the same instant breaks services with a cached JWKS. Publish first, wait for caches to refresh, then switch signing, then retire the old key after the longest token lifetime.
- Session fixation and unhashed storage. Issue a new session id at login, and store only hashes of session ids.
Choosing
| Situation | Prefer | Why |
|---|---|---|
| Server-rendered web app, one backend | Opaque session in HttpOnly cookie | Instant revocation, tiny token, simplest |
| SPA with your own backend | BFF with session cookie | No bearer token in the browser |
| Many APIs, high request rates | Short-lived JWT access tokens | Local verification, no shared store on the hot path |
| Mobile or third-party clients | JWT or opaque access + rotating refresh | Standard OAuth; revocation through refresh |
| Service to service | Edge-minted short JWTs | Identity propagation without session lookups |
| High-value actions | Introspection or re-authentication | Need current truth, not a cached claim |
What to do next
- Write down, for each client type you serve, the token format and the transport separately; fix any place where one choice was made to dodge the other.
- Measure your largest real access token and compare it with your proxy's header limit.
- Pick a revocation story for every JWT you issue: lifetime alone, denylist, user version or introspection, and test that logout actually stops access within that bound.
- Move any bearer token out of localStorage, using an HttpOnly cookie or a backend-for-frontend.
- Implement refresh token rotation with reuse detection and an atomic check.
- Rehearse a signing key rotation in staging with cached JWKS clients.