JWT Security: Decoding, Attacking, and Defending JSON Web Tokens
JWTs are everywhere and misused everywhere. A practitioner walkthrough of how they work, the classic attacks (alg none, key confusion, weak secrets), and how to validate them safely.
Why JWTs deserve a security review
JSON Web Tokens are the default currency of modern authentication. They carry session state, API authorisation and identity claims across services. They are also one of the most consistently misimplemented pieces of appsec, because the format looks simple and the signature verification is easy to get subtly wrong.
This guide walks through the structure, the attacks that keep working in the wild, and the validation rules that stop them.
Anatomy of a JWT
A JWT is three Base64URL-encoded parts joined by dots: header.payload.signature.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0Iiwicm9sZSI6InVzZXIiLCJleHAiOjE3MDAwMDAwMDB9.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Decode the first part and you get the header:
{
"alg": "HS256",
"typ": "JWT"
}
The second part is the payload (the claims):
{
"sub": "1234",
"role": "user",
"exp": 1700000000
}
The third part is the signature, computed over base64url(header) + "." + base64url(payload) using the algorithm named in the header.
Two things matter here. First, the header and payload are encoded, not encrypted. Anyone holding the token can read them. Never put secrets in a JWT payload. Second, the header is attacker-controlled input. The alg field tells the server how to verify, and trusting it blindly is the root of several attacks below.
You can inspect any token safely with the browser-based JWT decoder on mlab.sh. It decodes locally, so you can look at a token from a bug report or a log without pasting it into a remote service. Decoding is read-only and does not verify the signature, which is exactly the mindset you want: treat the contents as untrusted claims until verification proves otherwise.
Attack 1: alg none
The JWT spec allows an algorithm called none, meaning "unsigned token." It exists for cases where integrity is guaranteed by another layer. Attackers love it.
The attack: take a valid token, change the header algorithm to none, edit the payload to elevate your privileges, and drop the signature entirely.
{
"alg": "none",
"typ": "JWT"
}
{
"sub": "1234",
"role": "admin",
"exp": 9999999999
}
The resulting token is base64url(header).base64url(payload). with an empty signature. If the server reads alg from the header and dispatches verification accordingly, it sees none, performs no verification, and accepts a forged admin token.
The fix is to never accept none in production and to pin the expected algorithm server-side rather than reading it from the token.
Attack 2: HS256 / RS256 key confusion
This is the classic and it still lands. HS256 is symmetric: the same secret signs and verifies. RS256 is asymmetric: a private key signs, and the public key verifies. The public key is, by design, public.
The confusion attack works when a server expects RS256 but a permissive library lets the algorithm float based on the header. The attacker:
- Obtains the server's RSA public key (often published at a JWKS endpoint or extractable from TLS).
- Crafts a token with the header set to
HS256. - Signs it with HMAC-SHA256 using the public key bytes as the HMAC secret.
When the server verifies, a naive implementation grabs its RSA public key, sees alg: HS256, and runs HMAC verification with that public key as the secret. The signature matches, because the attacker used the same value. Forged token accepted.
import jwt # PyJWT
public_key = open("server_public.pem").read()
forged = jwt.encode(
{"sub": "1234", "role": "admin"},
key=public_key, # the PUBLIC key used as an HMAC secret
algorithm="HS256",
)
The defence is the same principle as before: pin the algorithm. Tell the verifier exactly which algorithm to allow, and reject anything else before any key is chosen.
# safe: only RS256 is accepted, header alg cannot downgrade it
decoded = jwt.decode(
token,
key=public_key,
algorithms=["RS256"], # explicit allowlist
)
Attack 3: weak secrets and offline cracking
HS256 is only as strong as its secret. Because verification happens with the same key that signs, an attacker who cracks the secret can mint arbitrary valid tokens. And a JWT is an offline oracle: the attacker has the signed data and the signature, so they can brute-force the secret without ever touching your server.
# hashcat mode 16500 cracks HS256 JWTs against a wordlist
hashcat -a 0 -m 16500 token.jwt rockyou.txt
If your secret is secret, password, your company name, or anything in a wordlist, it will fall in seconds. Use a high-entropy random secret of at least 256 bits, store it in a secrets manager, and rotate it. Better still, for anything crossing a trust boundary, prefer asymmetric RS256 or ES256 so the verifying party never holds signing material.
Attack 4: kid injection
The header can include a kid (key ID) telling the server which key to use for verification. If the server uses that value unsafely, it becomes an injection point.
- Path traversal:
"kid": "../../dev/null"can point the server at a predictable file whose contents the attacker knows, letting them forge a matching signature. - SQL injection: if
kidis used to look up a key in a database without parameterisation, a payload like"kid": "x' UNION SELECT 'known_secret' -- "can return an attacker-controlled key.
The fix is to treat kid as untrusted input. Validate it against an allowlist of known key identifiers, never use it to build file paths or raw SQL, and fail closed when it does not match a known key.
Attack 5: accepting expired or unbound tokens
Not every failure is a signature bypass. Plenty of systems verify the signature correctly and then ignore the claims.
- No expiry check: if you do not validate
exp, a token stolen months ago still works. - Missing
nbf/iatchecks let tokens be used outside their intended window. - No audience or issuer check: if you skip
audandiss, a token minted for one service can be replayed against another that shares the key. - Accepting alg mismatches in the claims validation stage.
A verified signature only proves the token was issued by a holder of the key. It says nothing about whether the token is still valid, meant for you, or meant for now. Validate the claims too.
The safe validation checklist
Put together, safe JWT handling comes down to a short list you should be able to point at in code review.
| Control | Rule |
|---|---|
| Algorithm | Pin an explicit allowlist. Reject none and any unexpected algorithm. |
| Key confusion | Never let the header choose between symmetric and asymmetric verification. |
| Secret strength | 256-bit random HMAC secrets, or use RS256/ES256 across trust boundaries. |
| kid handling | Allowlist known key IDs. No file paths or raw SQL from kid. |
| Expiry | Validate exp, and nbf / iat where present. |
| Audience / issuer | Verify aud and iss match the current service. |
| Library | Use a maintained library and read its defaults. Old versions accepted none. |
| Payload hygiene | No secrets in claims. The payload is readable by anyone. |
When not to use a JWT
JWTs are a good fit for stateless, short-lived authorisation across services. They are a poor fit for long-lived sessions you need to revoke instantly, because a self-contained token stays valid until it expires no matter what your backend thinks. If instant revocation matters, use opaque session tokens backed by server-side state, or pair short-lived JWTs with a refresh mechanism and a revocation list.
The rule of thumb: use JWTs when the value of statelessness outweighs the cost of not being able to kill a token on demand. When it does not, a plain session identifier is simpler and safer.
A JWT is signed data, not trusted data. Verify the algorithm you expect, check the claims you rely on, and never let the token tell you how to trust it.