Topic 26

JWT Anatomy and Its Traps

Identity

A JSON Web Token is three base64url segments joined by dots: a header that names the signing algorithm, a payload of claims, and a signature computed over the first two. That is the format, all of it, and the verifier that checks it has more ways to be wrong than the token has parts. Stagedoor's first implementation had four of them at once: it accepted alg: none as "no signature required," it took the algorithm from the header rather than from its own config, it checked the signature and nothing else so an expired token was as good as a fresh one, and it put the buyer's email and role in the payload, where every proxy log that recorded the header now keeps them.

Each of those is a named vulnerability class with public exploits, not a theoretical concern. This topic takes one token apart, lists the five checks a verifier must make on every token with no exceptions, names the two algorithm traps and the one-line fix for both, and ends with the rule about where a JWT belongs, because the answer is "fewer places than the first implementation used it."

The Three Parts

The header is JSON: the algorithm, and a key id when more than one key exists. The payload is JSON: a set of claims, of which seven have registered names. sub is the subject, the user id. iss is the issuer, who signed it. aud is the audience, who it is for. exp is when it stops being valid, iat when it was issued, nbf when it starts, all as Unix seconds, and jti is a unique id for this token. The signature is the algorithm applied to the two encoded segments and the key.

One access token from Stagedoor, decoded: readable by anyone who holds it
eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMDkifQ
  .eyJzdWIiOiI4MTIiLCJpc3MiOiJodHRwczovL3N0YWdlZG9vci5leGFtcGxlIiwiYXVkIjoic3RhZ2Vkb29yLWFwaSIsImlhdCI6MTc5MDQ1MTcyMCwiZXhwIjoxNzkwNDUyNjIwLCJqdGkiOiIwMUo5VjNNOFFLN1IifQ
  .<64-byte Ed25519 signature over the two segments above>

# header, base64url-decoded
{"alg": "EdDSA", "kid": "2026-09"}

# payload, base64url-decoded: no email, no role, nothing that is not an id or a time
{"sub": "812", "iss": "https://stagedoor.example", "aud": "stagedoor-api",
 "iat": 1790451720, "exp": 1790452620, "jti": "01J9V3M8QK7R"}

The token is for user 812, issued by Stagedoor, addressed to Stagedoor's own API, issued at 19:42 and expiring at 19:57, 15 minutes later, with a unique id. The header says the signature is EdDSA under the key named 2026-09. Every character of the first two segments is readable by anyone who has the token: base64 is an encoding, not encryption, and decoding it takes one line in any language. That is why the payload holds nothing but identifiers and timestamps. A JWT is a signed postcard, not a sealed envelope, and anything that would be a secret on a postcard is a secret in a JWT.

The Five Checks, Every Time

A verifier accepts a token only after five checks, and skipping any one of them is a distinct bypass. The signature must be valid under the algorithm and key the verifier expected, not the algorithm the token named. The issuer must be the one this service trusts, or a token from any other issuer with a leaked key is accepted. The audience must name this service, or a token that Stagedoor's identity provider issued for the mobile app's analytics vendor is accepted by the ticketing API. The expiry must be in the future, with a leeway of 30 seconds for clock skew between hosts and no more, or a token stolen last month works today. And if nbf is present it must be in the past.

The library does not do these by default, or does some of them by default and not others, and the defaults change between versions. Stagedoor's verifier calls the library with every check named explicitly: the allowed algorithm, the expected issuer, the expected audience, and the leeway, and treats a library that cannot be told all of them as a library not to use. The test suite of Chapter 12 has one test per check, each presenting a token that fails exactly that check and asserting a 401. The jti is the sixth thing, used only where a token must be single-use, like the reset flow of Topic 24, and it needs a store to remember which ids were seen, which is the session store again.

Five checks, and the token each one refuses
Signature valid under the verifier's own algorithm and keyrefuses a forged or altered token, and alg: none
iss equals the issuer this service trustsrefuses a token from any other signer
aud names this servicerefuses a valid token issued for a different service
exp in the future, 30 s leewayrefuses a token stolen after its 15 minutes
nbf in the past, if presentrefuses a token presented before it is meant to be valid

The Algorithm Traps

The JWS specification lists none as a valid algorithm, meaning an unsigned token, and in 2015 several widely used libraries treated a header of alg: none as an instruction: skip the signature check, the token says it has none. An attacker decodes any token, changes sub to another user, sets the algorithm to none, drops the signature, and the verifier says yes. Stagedoor's library was one of them, three major versions behind, and the security review reproduced the forgery in 4 minutes.

The second trap is quieter. A verifier configured with an RSA public key expects RS256 tokens: the identity provider signs with the private key, the verifier checks with the public one, and the public key is public. If the verifier takes the algorithm from the header, an attacker sets it to HS256, which is HMAC, and signs the forged token using the public key as the HMAC secret. The verifier reads HS256, uses the key it holds, which is the public key, as the HMAC secret, and the signature matches, because the attacker used the same bytes. This is algorithm confusion, and it works against any verifier that lets the token choose. The fix for both traps is one rule: the verifier pins its algorithm in its own configuration and never reads alg from the token, treating any mismatch as a forgery. Modern libraries require the caller to pass an explicit algorithm list for this reason; a library that does not is a library from before the lesson.

Symmetric vs Asymmetric

HS256 is HMAC with a shared secret: whoever can verify can also sign, because verification is recomputing the signature. That is right for exactly one shape, a service verifying tokens it issued itself, where the secret never leaves the process. It is wrong the moment a second party needs to verify, because handing them the secret hands them a signing key. Stagedoor's first design shared its HMAC secret with a partner "so they could check our tokens," and the partner could, from that day, mint a token for any buyer.

EdDSA and RS256 are asymmetric: the signer holds a private key, every verifier holds the public one, and the public one can be published on a web page without letting anyone sign. That is the shape of the identity provider in Topic 27, which signs id tokens that Stagedoor verifies, and of the CDN edge that verifies a signed download link for a ticket PDF without ever being able to forge one. Stagedoor signs its own access tokens with Ed25519 anyway, because the key is 32 bytes, the signature is 64, the operation is fast, and the day a second verifier appears is the day nothing has to change. RSA is the choice only where a verifier does not support EdDSA, which in 2026 is a shrinking list.

Key Rotation and kid

Keys expire, and a key that has signed tokens for two years is a key that has had two years to leak. Stagedoor rotates its signing key every 90 days. The rotation adds a new key, marks it current, and keeps the previous one for verification only; tokens name their key with kid, the verifier holds both, and a token signed 10 minutes before the rotation still verifies under the previous key until its 15 minutes are over. A rotation that forgets the previous key logs every app user out at the same second, which is not a security event but looks like one on the dashboard.

For asymmetric keys, the public halves are published at a JWKS endpoint, a JSON document listing each key with its kid. A verifier fetches the document, caches it for an hour, and on a token with an unknown kid refetches once before refusing, which is how a rotation propagates to verifiers without a deploy. Stagedoor consumes Google's JWKS in Topic 27 exactly this way, and publishes its own at /.well-known/jwks.json for the CDN edge.

Where a JWT Belongs

Between the identity provider and the service, as the id token that says "this provider vouches for this subject," verified with the provider's public key. Between the service and a verifier that cannot reach the session store, the CDN edge or a partner. As the 15-minute access token for the mobile app, where verification without a lookup is the point and the refresh token carries the revocable half. Those are the three places, and the token holds identifiers and times in all of them.

Not as a browser session, which Topic 25 settled: the cookie is automatic and revocable, and a JWT in a cookie has given up revocation for a round trip nobody notices. Not as a place to keep state, a shopping cart or a set of preferences, because the client can read it, cannot be stopped from replaying an old one, and the 4 KB header limit arrives sooner than expected. Not with a 30-day expiry, which is the long-lived JWT of Topic 25's first mistake. And not as a place for a role, which Topic 29 explains: a role claim is a fact frozen at issue time, and the staff member removed at 19:45 keeps scanning until 19:57.

JWT vs Opaque Token

An opaque token is a random string the issuer looks up: it carries nothing, only the issuer can verify it, and it can be revoked by deleting the row. Stagedoor's session id and refresh token are both opaque tokens. Use it wherever revocation matters and the verifier is the issuer.

A JWT carries its claims and its proof; anyone holding the key can verify it offline, and nobody can revoke it before exp. Use it where offline verification is the point: a second service, an edge, a provider vouching for a user, or a short-lived access token whose loss is bounded by its 15 minutes.

Common Mistakes
  • Taking the algorithm from the header — alg: none skips the signature and algorithm confusion signs with the public key; either way the check passes on a forged token, and the fix is one pinned algorithm in the verifier's config.
  • Verifying the signature and nothing else — an expired token, a token for another service and a token from another issuer are all accepted, because each carries a signature that is perfectly valid.
  • Sensitive data in the payload — the buyer's email and role sit in a token that every proxy log, crash report and browser history recorded, and base64 hides nothing from anyone who looks.
  • One HMAC secret shared with a partner "so they can verify" — the partner can now sign, and a token for any buyer can be minted from their side with no trace on yours.
  • Dropping the previous key on rotation — every access token in flight fails at the same second, 40,000 app users are logged out at once, and the dashboard reads like an outage.
  • A role claim in the token — the staff member removed at 19:45 keeps her permissions until 19:57, because the verifier trusts a fact that was true when the token was issued.
Best Practices
  • Pin the algorithm and the key in the verifier's own configuration, pass the allowed algorithm list explicitly to the library, and never read alg from the token.
  • Check signature, issuer, audience, expiry and not-before on every verification, each named explicitly in the call, with one test per check that presents a token failing only that check.
  • Sign with an asymmetric key, Ed25519 by default, name it with kid, and publish the public keys at a JWKS endpoint for any verifier outside the issuer.
  • Rotate signing keys every 90 days, keeping the previous key for verification until every token it signed has expired.
  • Issue access tokens of 15 minutes carrying identifiers and timestamps only: no state, no secrets, no email, no role.
Comparable toolsPyJWT the Python library that requires an explicit algorithm list; python-jose the older one, with an algorithm-confusion advisory in 2024jose (Node), Nimbus JOSE + JWT (Java), golang-jwt (Go) the same five checks under other namesJWKS endpoints of every identity provider, the published half of every asymmetric keyPASETO the alternative that removed the algorithm choice from the token on purpose

Knowledge Check

A colleague proposes putting the buyer's email in the access token "so the handler does not need a lookup." What is the objection?

  • The email is safe in the token because base64 hides it from anyone without the key
  • The token would exceed the header size limit that browsers and proxies enforce
  • Anyone who logs or captures the token reads the email in plain text, with no key needed
  • The signature does not cover the payload, so an attacker could rewrite the email claim freely

Stagedoor's verifier holds an RSA public key and reads the algorithm from each token's header. How does an attacker forge a token?

  • Set alg to HS256 and sign with the public key as the HMAC secret
  • Derive the private key from the public one, since RSA keys are related
  • Change the sub claim and keep the original signature, which still matches
  • Replay an expired token, because reading alg from the header also skips exp

Which check refuses a token that was legitimately issued by Stagedoor's own identity provider for the analytics vendor's API?

  • The signature check, because the token was signed for a different key
  • The issuer check, since a token for another service names another issuer
  • The expiry check, because tokens for other services get a different lifetime
  • The audience check, because aud names the vendor's API rather than the ticketing service

Marek rotates the signing key and removes the old one in the same deploy. What happens in the next 15 minutes?

  • Tokens signed with the old key can be forged, because a removed key is no longer checked
  • Every access token in flight fails verification, and every app user hits 401 at once
  • The verifier re-signs old tokens with the new key on first use, so nothing is visible
  • Nothing for an hour, because verifiers cache the JWKS and still hold the old key

You got correct