JSON Web Tokens (JWT)
§Basic information
A JSON Web Token is a base64url-encoded header.payload.signature triple. The server hands it to the client after login, the client sends it back on every request, and the server is supposed to trust the claims inside (sub, email, jti, groups, exp) only if the signature verifies with the expected key and the expected algorithm. That single check is the entire security boundary.
Every JWT bug is one variation on the server skipping, weakening, or misconfiguring that check and then trusting attacker-controlled claims as identity. Because the payload is the identity, a token you can forge or replay makes you whoever the payload says you are — so JWT flaws are best treated as direct authentication-bypass and account-takeover primitives, not crypto curiosities. The three parts are all attacker-visible and attacker-editable; the only thing standing between you and impersonating any user is whether the verifier does its job.
§Methodology
- Grab a token and decode it locally. Split on the dots,
base64url -dparts 1 and 2. Never paste a live-engagement token into a hosted decoder — decode offline. - Read the header (
alg,kid,jku,x5u) and note which payload claim carries identity (sub,email,jti,uid,u,groups). That claim is the one you flip. - Fingerprint the library/stack — Node
jsonwebtoken, PyJWT,jose, Prosody/luajwtjitsi, Auth0, Keycloak, Argo CD — each has its own signature failure modes. - Run the differential ladder below: mutate the token so it should be rejected, and watch for the tell — the server still accepts it.
- Confirm identity flipped, not just "200 OK". Look at a
/me-style echo, or fetch a resource only the victim can see. - For microservice fleets, replay the same tampered token against every distinct backend and diff which ones accept it — validation is rarely uniform.
§Signature & validation attacks
Work down this ladder against the specific library you fingerprinted. Each is an independent way the verifier's job goes undone.
No signature verification (decode vs verify)
The single most common JWT bug. The middleware reads the token with a decode call (jwt.decode(), jwt_decode(), or a hand-rolled base64 split) that parses the payload without checking the signature, then trusts the identity claim inside. Mutate the identity claim, leave the signature field arbitrary, and you are that user. Test by changing only the payload and keeping the old (now-invalid) signature — if it still authenticates, nothing verifies it.
Some login endpoints trust an email/sub claim after a decode. Craft the payload, sign with anything, POST it:
POST /token cryptographically verifies only the refresh token and then merely decode()-compares the access token, tamper the access token's payload — the server re-signs your forged claims into a fresh, validly-signed token. Both tokens in a compare/reissue flow must be verified, not decoded.alg:none
The JWT spec allows an alg of none — an unsecured token with no signature at all. A verifier that honours the token's own alg header will accept it with an empty signature. Set the header, keep the trailing dot, drop the signature.
none is blacklisted, try case and spacing variants — None, NONE, nOnE — many blacklists match only the exact lowercase string while the library lowercases before dispatch.Algorithm confusion (RS256 → HS256)
The token was issued RS256 (asymmetric: private key signs, public key verifies). If the verifier picks the algorithm from the token's alg header instead of pinning it to the configured key type, switch alg to HS256 and HMAC-sign with the RSA public key bytes as the secret. The public key is not secret — it is published in JWKS, a cert, or the TLS chain — so it becomes a perfectly good HMAC key the server will accept.
Weak or leaked HMAC secret
HS256 security collapses if the shared secret is guessable (secret, changeme, a framework default) or exposed. Crack it offline; if you recover it, you can mint any token. Client-side SSO is the classic exposure — an SPA that HMAC-signs the JWT in the browser ships the secret in the JS bundle or a leftover .js.map sourcemap.
kid / jku / x5u header injection
Header parameters tell the verifier where to find the key. If they are trusted unsanitised, you control the key.
kid(key id) is often used as a filename or DB lookup — path-traversal it to a file with known contents (sign with that content), or SQL/command-inject it.jku/x5upoint to a JWKS/cert URL — if the host allowlist is missing, host your own key set and point the verifier atCOLLAB.
Claim tampering (exp / aud / sub)
Even a verified signature is not enough if the claims go unchecked. If a backend skips proper exp handling, a past-dated token still passes. If aud is not enforced, a validly-signed token minted for a different audience of the same IdP is accepted and mapped to local privileges through its groups claim.
§Bypasses
Each row is a real validation or token-trust failure; "Seen in" cites the report it came from.
| Filter / control | Bypass | Seen in |
|---|---|---|
| Middleware reads the token | jwt.decode() (no sig check) trusts the payload identity — flip the claim, keep the old signature | #748214 |
| Reissue endpoint verifies a token | /token verifies only the refresh token, decode()-compares the rest — tamper the access payload, it gets re-signed | #736522 |
| Login endpoint "checks" the JWT | Payload decoded, email claim trusted; unsigned / wrong-key token accepted | #2472798 |
| RS256 asymmetric verification | alg not pinned to key type — switch to HS256, sign with the RSA public key as the HMAC secret | #1210502 |
| Signature verified (false confidence) | aud never validated — replay a signature-valid token from a sibling IdP audience | #1889161 |
| Expiry validation | Some backends skip exp/signature checks — a past-dated exp still passes | #1760403 |
| Secret kept "server-side" | HMAC signing secret shipped in the JS bundle / .js.map sourcemap — mint any-email token | #638635 |
| Authz on the admin UI page | The JWT-minting get-started/connect servlet behind it enforces no role check | #1103582 |
| Token revoked at the IdP | No server-side revocation — token trusted until exp after account disable/delete | #2122690 |
| Session lifetime | Long-lived, non-revocable OIDC/SIWA bearer token — one interception = durable multi-device ATO | #1593413 |
aud, exp, iss, nbf, or whether the account still exists. The aud-skip (#1889161) and the past-exp (#1760403) cases both have perfectly valid signatures.§Escalation & impact
- JWT forge → full account takeover — the dominant outcome; impersonate any user by mutating the single identity claim the server reads for authz (
jti,email,sub,u). Almost every JWT flaw in the corpus terminates in ATO. - Missing
aud→ privilege viagroups— a signature-valid token from a sibling audience maps to admin through its group claim (#1889161); pair with the