JWT — Structure, Guarantees, and Attacks
JWT — Structure, Guarantees, and Attacks
Section titled “JWT — Structure, Guarantees, and Attacks”Structure
Section titled “Structure”JWT = base64url(header) . base64url(payload) . signature └──── readable by anyone ────┘ └── cryptographic ──┘// header{ "alg": "HS256", "typ": "JWT" }// payload{ "sub": "1234", "role": "user", "exp": 1710000000 }Three parts, dot-separated. The first two segments are base64url of JSON — readable by anyone holding the token. The third is an HMAC or digital signature over header.payload.
Naming note: what everyone calls “JWT” is technically JWS (signed, 3 segments). JWE (encrypted, 5 segments) is a different and rare construct. Default assumption: signed, readable.
What the signature actually guarantees
Section titled “What the signature actually guarantees”Two properties from one HMAC:
Integrity → payload unmodified since issuanceAuthenticity → issued by the holder of the keyThe correct frame is integrity + authenticity — not confidentiality.
Attacker changes "role":"user" → "role":"admin" → signature breaks → rejected ✓ (tamper-evidence working)
Attacker steals the whole valid token → signature intact → accepted ✗ (integrity offers nothing here)The key boundary: an attacker who steals a token does not need to modify it. The signature is intact, which is exactly why the attack works. Signature protects against tampering, not theft.
| Property | Covered by JWT signature? |
|---|---|
| Payload unmodified | ✓ |
| Issued by the key holder | ✓ |
| Payload confidential | ✗ — base64 is readable. Never put secrets in the payload. |
| Freshness | Only if exp / iat / nbf are actually checked |
| Revocation | ✗ — a valid unmodified token stays valid until exp |
Therefore: unmodified ≠ still authorised. exp validation, short lifetimes, refresh-token rotation, and token binding all exist because integrity does not cover theft.
The conditional guarantee: the “doesn’t change” property holds only if the server calls verify(), not decode(). jwt.decode() in every major library skips signature checking entirely. Decode-and-trust is the #1 real-world JWT bug — the signature becomes decoration.
The five signature-verification attacks
Section titled “The five signature-verification attacks”Every JWT attack is an attack on signature verification. The payload is public; the signature is the security.
1. alg: none
Section titled “1. alg: none”Server accepts unsigned tokens. Set the header to {"alg":"none"}, forge any payload, drop the signature segment.
eyJhbGciOiJub25lIn0.eyJyb2xlIjoiYWRtaW4ifQ.Mitigation: never accept none; allow-list the algorithm server-side.
2. Algorithm confusion (RS256 → HS256)
Section titled “2. Algorithm confusion (RS256 → HS256)”Server is configured for RS256 (asymmetric — signs with a private key, verifies with a public key). Attacker switches the header to HS256 and signs with the public key as the HMAC secret. The server, using the same public key as its HMAC secret, verifies successfully.
Intended: RS256 private key sign → public key verifyAttack: HS256 public key sign → public key verify ✓ passesMitigation: bind the expected algorithm explicitly; never derive it from the token header.
3. Weak HMAC secret
Section titled “3. Weak HMAC secret”With a valid token in hand, the secret can be brute-forced offline — no server round-trip, no rate limit.
hashcat -m 16500 token.txt wordlist.txtCommon finds: secret, changeme, jwt_secret, project names. Mitigation: long random secret (32+ bytes); prefer asymmetric.
4. kid header injection
Section titled “4. kid header injection”The kid (key ID) header tells the server which key to use. If it is used unsanitised in a file path or SQL query → path traversal or SQL injection into the key lookup.
{"alg":"HS256","kid":"../../../../dev/null"} → key resolves to empty string{"alg":"HS256","kid":"key' UNION SELECT 'known'--"}Mitigation: treat kid as an index into a fixed key set, never as a path or query fragment.
5. Missing claim validation
Section titled “5. Missing claim validation”Signature valid, but exp never checked → token valid forever. aud / iss unchecked → a token minted for a different service is accepted here.
Mitigation: validate exp, nbf, iss, aud on every request.
Tamper vs theft — the operational split
Section titled “Tamper vs theft — the operational split”| Threat | Signature helps? | Actual control |
|---|---|---|
| Payload modification | ✓ blocked | signature verification |
| Token theft | ✗ | short exp, refresh rotation, HttpOnly cookie, token binding |
| Replay | ✗ | jti + server-side blocklist, short lifetimes |
| Revocation | ✗ | server-side state (JWTs are stateless by design) |
This is why “where do we store the token” is a real design question: localStorage (XSS-readable) vs HttpOnly cookie (not JS-readable, but CSRF-exposed).
One-line summaries
Section titled “One-line summaries”JWT is not encryption. It's tamper-evidence on a public payload.Payload = encoding (transparent, public)Signature = authentication (the actual security)If it's read without verifying, it's just JSON with extra steps.Unmodified ≠ authorised.Sources
Section titled “Sources”- RFC 7515 (JWS), RFC 7519 (JWT), RFC 8725 (JWT Best Current Practices)
- OWASP API Security Top 10 — API2 Broken Authentication
- CAPIE course section 1.3 (Auth & Authorization)