Skip to content

JWT — Structure, Guarantees, and Attacks

JWT — Structure, Guarantees, and Attacks

Section titled “JWT — Structure, Guarantees, and Attacks”
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.

Two properties from one HMAC:

Integrity → payload unmodified since issuance
Authenticity → issued by the holder of the key

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

Every JWT attack is an attack on signature verification. The payload is public; the signature is the security.

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.

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 verify
Attack: HS256 public key sign → public key verify ✓ passes

Mitigation: bind the expected algorithm explicitly; never derive it from the token header.

With a valid token in hand, the secret can be brute-forced offline — no server round-trip, no rate limit.

Terminal window
hashcat -m 16500 token.txt wordlist.txt

Common finds: secret, changeme, jwt_secret, project names. Mitigation: long random secret (32+ bytes); prefer asymmetric.

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.

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.

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

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.
  • 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)