JSON Web Tokens are everywhere — API gateways, OAuth flows, session management. They’re also regularly misimplemented in ways that hand attackers full account takeover. This post covers how JWTs actually work, what the spec requires, and where things go wrong.
Structure
A JWT is three base64url-encoded segments joined by dots:
header.payload.signature
Header — declares the token type and signing algorithm:
{
"alg": "RS256",
"typ": "JWT"
}
Payload — contains claims. Registered claims (RFC 7519) include iss, sub, aud, exp, nbf, iat, jti. Everything else is application-defined:
{
"sub": "u_9f3a1b",
"iat": 1751740800,
"exp": 1751827200,
"roles": ["admin"]
}
Signature — computed over base64url(header) + "." + base64url(payload) using the key and algorithm declared in the header.
For RS256:
signature = RSASSA-PKCS1-v1_5-Sign(SHA256(header.payload), privateKey)
The recipient verifies with the corresponding public key. The payload is not encrypted — it’s just encoded. Anyone with the token can read the claims. If you need confidentiality, use JWE (JSON Web Encryption).
Verification Flow
A correct verifier must:
- Split the token into three segments.
- Base64url-decode the header and parse the JSON.
- Validate
algagainst an allowlist — never trust the header’s algorithm blindly. - Fetch the correct key (by
kidif present) from a trusted key store. - Verify the signature over
header.payload. - Validate standard claims:
exp(not expired),nbf(not before),aud(intended audience),iss(expected issuer). - Only then trust the payload claims.
Step 3 and step 6 are where most implementations fail.
Attack Vectors
Algorithm Confusion (alg: none)
Early JWT libraries accepted "alg": "none" as a valid algorithm, treating unsigned tokens as valid. An attacker could strip the signature, set alg to none, and forge arbitrary payloads.
Fix: Hardcode the expected algorithm server-side. Reject any token whose header alg doesn’t match.
# Wrong — trusts the header
decoded = jwt.decode(token, key, algorithms=jwt.get_unverified_header(token)["alg"])
# Correct — enforces algorithm
decoded = jwt.decode(token, key, algorithms=["RS256"])
RS256 to HS256 Confusion
When a server supports both asymmetric (RS256) and symmetric (HS256) algorithms, an attacker can submit a token with "alg": "HS256" and sign it using the server’s public key as the HMAC secret. If the library picks the algorithm from the token header and the server has the public key loaded, verification passes.
This is a variant of the same root cause: the algorithm must be fixed by the verifier, not negotiated from the token.
JWT Secret Brute-Force
HS256/HS384/HS512 tokens are only as strong as the secret. Weak secrets are trivially brute-forced with tools like hashcat:
hashcat -a 0 -m 16500 token.jwt rockyou.txt
Use secrets with at least 256 bits of entropy for HMAC-signed JWTs. For production systems, prefer RS256 or ES256 — asymmetric schemes separate signing from verification and make secret rotation operationally simpler.
Claim Validation Bypass
The exp, aud, and iss claims are only meaningful if the verifier checks them. Many libraries require explicit opt-in:
# PyJWT — audience and issuer validation must be explicit
jwt.decode(
token,
key,
algorithms=["RS256"],
audience="api.example.com",
issuer="https://auth.example.com"
)
If audience and issuer are omitted from the decode call, a token issued for a different service or by a different provider may be accepted.
kid Header Injection
The kid (Key ID) header is used to select which key to verify with. If a library passes kid directly into a database query or file path to fetch the key, injection attacks are possible:
kid: "../../dev/null" # Empty key → empty HMAC secret → trivially forgeable
kid: "x' UNION SELECT 'attacker-key'--" # SQL injection to control the key
Fix: Validate kid against a strict allowlist of known key identifiers before any lookup.
Token Lifecycle
Short-lived tokens reduce the blast radius of compromise. Access tokens should expire in minutes to hours; refresh tokens should be rotated on use (refresh token rotation) and stored in httpOnly cookies, not localStorage.
There is no native JWT revocation — the token is valid until exp. If you need revocation (e.g., logout, compromise response), maintain a server-side blocklist keyed on jti. This reintroduces statefulness, but it’s unavoidable for hard revocation requirements.
Key Rotation
For RS256/ES256, publish your public keys via a JWKS endpoint (/.well-known/jwks.json). Each key should have a unique kid. Rotate keys by adding the new key to the JWKS before issuing tokens signed with it, and retiring the old key after its tokens have expired.
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "2026-07",
"alg": "RS256",
"n": "...",
"e": "AQAB"
}
]
}
Summary
The security of JWT hinges almost entirely on the verifier. The token format is a transport mechanism — the trust model depends on:
- Fixed algorithm enforcement (never from the token header)
- Strict claim validation (
exp,aud,iss) - Proper key management and
kidsanitization - Appropriate secret entropy for HMAC schemes
Get those four right and JWT is a solid, stateless authentication primitive. Skip any one of them and the entire scheme collapses.