SessionsJWTtokensclaimssignaturestatelessexpiry

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.

▶ Run the labFollow the failure

Frame the problem

Security starts with a concrete asset, attacker capability and trust crossing.

Asset
The claims inside the token — identity, scopes, tenant — which downstream services will act on without consulting any other source.
Attacker & capability
Someone who obtains a token and replays it, and someone who tries to make your verifier accept a token it should not.
Trust boundary
The verification boundary: everything after it treats the claims as true, so the correctness of the check is the whole of the security.
AssetThreatAttack SurfaceTrust BoundaryVulnerabilityExploit PathImpactMitigationDefense in DepthResidual Risk

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.

A token, decoded — note that anyone holding it can read all of this
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 allowance

The 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.

Session cookie vs JWT, decided by what you need
Server-side sessionSigned JWT
ValidationLookup in a shared storeSignature check, no lookup
RevocationImmediate — delete the rowNot immediate without added state
Scale costOne store read per requestCPU for signature verification
Claim freshnessAlways current (role changes apply at once)Stale until expiry — a demoted user keeps their old role
Size on the wireSmall opaque idHundreds of bytes to kilobytes, every request
Data exposureNothing readable in the tokenAll claims readable by any holder
Best fitBrowser sessions, admin tools, anything needing logout-everywhereShort-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 kid from a trusted set, then iss, aud, exp and nbf — 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.

Boundary control check
Untrusted input / identity
Trust boundary
Privileged asset
Prevention may fail silently.

Follow the attack

Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.

  1. 1
    Attacker → obtain a token: from `localStorage` via XSS, from a log recording `Authorization` headers, or from a URL.
  2. 2
    Token → read: decode the payload and learn identity, tenant, scopes and whatever else was embedded.
  3. 3
    Token → replay: present it to any service that accepts that issuer, especially one that does not check `aud`.
  4. 4
    Replay → until expiry: with no revocation path, the token works until `exp`, regardless of what the account does.
Blast radius
  • A stolen token is valid until expiry with no way to stop it, unless revocation state was engineered in advance.
  • Missing aud checks 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.

Prevent
  • • 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.
Detect
  • • 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.
Respond & recover
  • • 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.
Residual risk
  • • 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.

Misconceptions

Claim
“JWTs are more secure than sessions.”
Reality
They are a different trade. They remove a lookup and add a revocation problem, and they publish their claims to anyone holding the token.
Claim
“JWTs are encrypted.”
Reality
Signed tokens are base64url-encoded plaintext with a signature. Encryption requires JWE, which is a separate and less commonly used construction.
Claim
“Stateless means no server state.”
Reality
Any real system with revocation, refresh tokens or a denylist has server state. The question is only how much and where.