JWT — What It Is and What It Costs
A signed, self-contained token that lets a service verify a claim without a lookup — which buys statelessness and pays for it with revocation you have to engineer separately.
Frame the problem
Security starts with a concrete asset, attacker capability and trust crossing.
Three parts, and what each one means
A JWT is three base64url segments joined by dots. The header names the algorithm and usually a key id. The payload holds claims: subject, issuer, audience, expiry, and whatever application data you added. The signature covers the first two and proves they were produced by a holder of the signing key.
The consequence people miss is stated plainly: signed is not encrypted. The payload is encoded, not concealed — anyone holding the token can read every claim in it. A JWT with a user's email, role, plan and internal ids is publishing all of that to anyone who intercepts it, and to any JavaScript that can read it if you store it client-side. Encryption is a separate mechanism (JWE); the plain signed token you get from most libraries is public text with a tamper-evident seal.
The claims that matter for verification are structural. exp bounds the lifetime. iss says who issued it. aud says who it was issued *for* — and checking it is what stops a token minted for service A being replayed against service B. nbf and iat bound validity in time. Each of these must be checked explicitly; a library that verifies the signature and returns the payload has done half the job.
eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMDgifQ . eyJzdWIiOiJ1XzQ0MSIsImF1ZCI6ImFwaS5leGFt... . MEUCIQD...
header { "alg": "RS256", "kid": "2026-08" }
payload { "sub": "u_441", ← who
"iss": "https://auth.example", ← who issued it MUST be checked
"aud": "api.example", ← who it is FOR MUST be checked
"exp": 1756300000, ← expiry MUST be checked
"iat": 1756299100,
"scope": "orders:read",
"tenant": "t_88" } ← readable by anyone holding the token
signature over base64url(header) || "." || base64url(payload), using the key named by kid
VERIFY, in this order:
1. alg is one you accept, from YOUR list — never from the token's header alone
2. kid resolves to a key in your trusted set (fetched from the issuer's JWKS, cached)
3. signature is valid over the exact encoded segments
4. iss is the expected issuer 5. aud contains you
6. exp is in the future 7. nbf/iat are sane, with small clock skew allowanceThe revocation problem, stated honestly
A server-side session is revoked by deleting a row: the next request fails. A JWT is valid because its signature is valid and its exp is in the future — no lookup, so nothing to delete. That is precisely the property that makes it fast and precisely the property that makes revocation hard.
The workarounds all reintroduce some state, and it is worth being clear that they do. A denylist of revoked token ids requires a lookup on every request, which is the lookup you avoided — though it is cheaper than a full session read and only needs to hold entries until they expire. Short expiry with refresh tokens limits the window: access tokens live minutes, refresh tokens are checked against server state and can be revoked, so revocation takes effect within one access-token lifetime. A version or epoch claim compared against a per-user counter turns revocation into "bump the counter", at the cost of one cheap read.
The design conclusion is not "JWTs are bad". It is that the stateless property is real and has a price, and the price is paid in revocation latency or in reintroduced lookups. For service-to-service calls with short-lived tokens, the trade is usually excellent. For browser sessions where users expect "log out everywhere" to work immediately, a server-side session is usually the simpler and better answer — see the comparison in Sessions.
| Server-side session | Signed JWT | |
|---|---|---|
| Validation | Lookup in a shared store | Signature check, no lookup |
| Revocation | Immediate — delete the row | Not immediate without added state |
| Scale cost | One store read per request | CPU for signature verification |
| Claim freshness | Always current (role changes apply at once) | Stale until expiry — a demoted user keeps their old role |
| Size on the wire | Small opaque id | Hundreds of bytes to kilobytes, every request |
| Data exposure | Nothing readable in the token | All claims readable by any holder |
| Best fit | Browser sessions, admin tools, anything needing logout-everywhere | Short-lived service-to-service auth, federated identity |
Where the claims should and should not come from
A JWT is a statement made by an issuer at a moment in time. Everything derived from it inherits that staleness. If the token carries role: admin and you revoke that role, the token keeps asserting it until it expires. For a five-minute access token that is an acceptable window; for a twelve-hour one it is a genuine authorization bug that will be found during an incident.
So put in the token only what is stable for its lifetime and needed by the recipient — subject, tenant, audience, coarse scopes. Fetch what must be fresh: fine-grained permissions, feature entitlements, account status. The rule of thumb: if changing it should take effect immediately, it must not live in a token whose lifetime exceeds "immediately".
And never put anything sensitive in it. Internal identifiers, email addresses, plan details and personal data in a JWT are readable by anyone who obtains the token — which includes browser extensions, proxies, logs that record Authorization headers, and any JavaScript on the page if you store it in localStorage. If a claim would be uncomfortable in a log file, it does not belong in a token, because it will end up in one.
Key points
- Signed is not encrypted: every claim is readable by anyone holding the token.
- Verify algorithm from your allow-list, key by
kidfrom a trusted set, theniss,aud,expandnbf— signature alone is not verification. - Statelessness costs revocation; every workaround reintroduces a lookup or a delay, and that is the honest trade.
- Claims are stale by construction — put stable facts in the token and fetch anything that must be current.
- For browser sessions where "log out everywhere" must work now, a server-side session is usually simpler and better.
Boundary control exercise
This lesson uses the shared boundary-control exercise.
Follow the attack
Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.
- 1Attacker → obtain a token: from `localStorage` via XSS, from a log recording `Authorization` headers, or from a URL.
- 2Token → read: decode the payload and learn identity, tenant, scopes and whatever else was embedded.
- 3Token → replay: present it to any service that accepts that issuer, especially one that does not check `aud`.
- 4Replay → until expiry: with no revocation path, the token works until `exp`, regardless of what the account does.
- A stolen token is valid until expiry with no way to stop it, unless revocation state was engineered in advance.
- Missing
audchecks let a token for one service act on another, crossing a boundary that was assumed. - Sensitive claims are disclosed wherever the token is recorded.
Defend, detect, recover
One prevention is a single point of security failure. Layer it and make failure observable.
- • Keep access-token lifetimes short (minutes) and pair them with revocable refresh tokens held server-side.
- • Validate `alg`, `kid`, `iss`, `aud`, `exp` and `nbf` explicitly; reject anything unexpected.
- • Store browser tokens in `HttpOnly` cookies rather than `localStorage` where the application shape allows it.
- • Keep claims minimal and non-sensitive; fetch fresh authorization data rather than embedding it.
- • Alert on tokens presented with an unexpected `iss` or `aud` — those are configuration errors or attacks, never normal.
- • Alert on the same token id used from many distinct sources.
- • Log token ids (`jti`), never token values, so replay can be traced without creating a new leak.
- • Revoke the refresh token and bump the user's token epoch so newly-minted tokens differ.
- • If neither mechanism exists, rotate the signing key — this invalidates every token, which is disruptive and is the reason to have the mechanism beforehand.
- • Audit what the token's scopes allowed and review those resources.
- • A stolen token remains valid for its lifetime; short expiry reduces but never removes this.
- • Claim staleness means permission changes lag by up to one token lifetime.
- • Key rotation is disruptive, so it is rarely rehearsed and often broken when needed.