JSON Web Tokens (JWT) have become the de facto standard for stateless authentication in APIs: the server issues a signed token, the client presents it again on every request, and the server trusts it as long as the signature checks out. The problem is that all of this trust hinges on a single fragile detail — signature verification. When it is poorly implemented, the token stops being a credential and turns into an editable form: the attacker rewrites who they are, what role they hold, and for how long, and the backend believes it.
JWT is one of those topics where pentesters and developers need to speak the same language. For attackers, it is one of the highest-return categories: a few clicks in Burp separate a regular user from an admin session. For developers, most of the flaws come from trusting fields that the attacker controls — starting with the alg header. This article covers the classic pitfalls (alg: none, algorithm confusion, weak secrets) and the less obvious ones (kid, jku, unvalidated claims, lack of revocation), with reproducible code from both sides.
Before attacking, understand the structure. If the acronyms JWT, JWS, JWE, JWA and JWK still blur together, start with JWT, JWS, JWE, JWA and JWK: the difference between the JOSE acronyms — it gives you the mental map that makes these attacks obvious.
How it works
A JWT is made up of three parts separated by dots: header.payload.signature. The first two are just base64url of a JSON object — there is no encryption, only encoding. Anyone can decode and read the content. The third part is the signature, which proves the token was issued by whoever holds the key.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJyb2xlIjoidXNlciJ9.K8w3Q...
└────────── header ──────────┘ └───────────── payload ─────────────┘ └─ signature ┘
Decoding the first two parts:
// header
{ "alg": "HS256", "typ": "JWT" }
// payload
{ "sub": "123", "role": "user", "exp": 1769900000 }
Note the most important point for a pentester: the payload is not secret. It is base64url, readable by anyone who intercepts the token. This is a property of JWS (JSON Web Signature) — the signed format, which is what the overwhelming majority of applications use. There is also JWE (JSON Web Encryption), which is actually encrypted, but it is rare in practice. How to tell them apart: a JWS has three parts (header.payload.signature); a JWE has five parts separated by dots. If you are looking at an eyJ... with three segments, it is JWS and the content is exposed.
The HS256 signature is an HMAC-SHA256 computed like this:
import hmac, hashlib, base64
def b64url(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
header = b64url(b'{"alg":"HS256","typ":"JWT"}')
payload = b64url(b'{"sub":"123","role":"user"}')
signing_input = f"{header}.{payload}".encode()
# The secret is shared: the SAME value signs and verifies
secret = b"super-secret-server-key"
sig = hmac.new(secret, signing_input, hashlib.sha256).digest()
token = f"{header}.{payload}.{b64url(sig)}"
The server verifies by recomputing the HMAC over header.payload with its secret and comparing it against the received signature (ideally in constant time, with hmac.compare_digest). If they match, it trusts everything in the payload. That is exactly where the vulnerability lives: if verification can be fooled, the attacker controls the claims.
Variations and bypasses
Each flaw below shares a common root — the backend trusts data under the attacker’s control (the header) or uses a secret that is far too weak.
1. alg: none accepted
The specification (RFC 7519/7518) defines a none algorithm, intended for “unprotected” tokens (Unsecured JWS). If the library accepts it during verification, the attacker simply removes the signature and swaps the algorithm:
{ "alg": "none", "typ": "JWT" }
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjMiLCJyb2xlIjoiYWRtaW4ifQ.
The token ends with a dot and nothing after it (the empty signature). Bypass variants include None, NONE, nOnE — some libraries did a case-sensitive comparison and blocked only lowercase none. In modern libraries that require an allowlist of algorithms at verification time, none only passes if it is explicitly included in that list; that is why the allowlist (see mitigation) kills this entire class.
2. Algorithm confusion (RS256 → HS256)
This is the most elegant one. In RS256, the private key signs and the public key verifies — and the public key is, by definition, distributable (often exposed at /.well-known/jwks.json). The attack exploits libraries that pick the verifier based on the token’s alg field:
- The server expects
RS256and only has the public key to verify. - The attacker forges a token with
alg: HS256. - They sign that token with HMAC using the public key (the PEM as text) as the secret.
- The library reads
alg: HS256, grabs “the server’s key” (the public one), and uses it as the HMAC secret — which is exactly what the attacker used. The signature matches.
The attacker does not need the private key: they turn public data into a symmetric secret. A critical reproduction detail: the HMAC secret has to be exactly the same bytes the server passes to the verifier (usually the full PEM, including the -----BEGIN PUBLIC KEY----- headers and line breaks). One byte off and the signature will not match.
# Algorithm confusion forgery — defensive demonstration.
# WARNING: PyJWT >= 1.5 BLOCKS this, raising InvalidKeyError
# ("asymmetric key ... should not be used as an HMAC secret").
# The attack only works against libraries that LACK this protection
# (old versions of various libs, or homegrown implementations).
# To reproduce against a vulnerable target use jwt_tool (further below).
import jwt # PyJWT
with open("public.pem") as f:
public_key = f.read() # PUBLIC key, obtained from the JWKS
forged = jwt.encode(
{"sub": "123", "role": "admin"},
key=public_key, # the public key becomes the "secret"
algorithm="HS256", # downgrade from RS256 to HS256
)
3. Weak / short HMAC secret
If the application uses HS256 with a short secret, a dictionary word, or an example value copied from a tutorial (secret, your-256-bit-secret, changeme), it is crackable offline. The attacker captures a single valid token and runs local brute force until they find the secret that reproduces the signature. From there, they can sign whatever they want. Note that this only applies to HMAC (HS*): an RS256/ES256 token is not “crackable” by wordlist, because verification uses the public key and forgery would require the private key.
4. Signature simply not verified
More common than it sounds: the backend decodes the token (reads the claims) but never calls the function that verifies the signature. In PyJWT, this is the difference between jwt.decode(token, options={"verify_signature": False}) and actual verification with a key and algorithms. In this scenario the algorithm does not matter — any edited payload is accepted.
5. Injection via the kid header
The kid (key ID) indicates which key to use. If the backend uses that value to look up the key without sanitization, it becomes an injection vector:
- Path traversal: point
kidat a file with known, predictable content and use it as the HMAC secret.
{ "alg": "HS256", "kid": "../../../../dev/null" }
If the server reads the file pointed to by kid and uses the content as the key, /dev/null returns empty content — the attacker signs with an empty secret ("") and verification passes. The same goes for any file whose content is stable and known to the attacker (the “key” becomes the content of that file).
- SQL Injection: if
kidfeeds a query (SELECT key FROM keys WHERE id = '<kid>'), the attacker injects SQL to make the database return a value they control, which is then used as the key.
{ "alg": "HS256", "kid": "x' UNION SELECT 'attacker-controlled-key' -- " }
6. jku / x5u pointing to the attacker
The jku (URL of a JWKS) and x5u (URL of an X.509 certificate) headers tell the server where to fetch the verification key. If the backend blindly trusts that URL, the attacker hosts their own JWKS, points jku at it, and the server downloads the attacker’s public key to verify — forged token validated. Beyond direct forgery, this turns into SSRF: the URL can point at internal services (cloud metadata, admin panels, etc.).
{ "alg": "RS256", "jku": "https://attacker.example/jwks.json", "kid": "1" }
7. exp / nbf / aud / iss not validated
Even with a correct signature, semantic validations may be missing:
expignored: tokens never expire; a leaked token is valid forever.aud(audience) ignored: a token issued for service A is accepted by service B (cross-service token reuse).iss(issuer) ignored: the backend accepts tokens from any issuer, including a tenant belonging to another customer in the same identity provider.nbf(not before) ignored: “future” tokens are accepted too early.
8. No revocation on logout
JWT is stateless by design — the server does not store a session. The consequence is that logout does not invalidate the token: it stays valid until exp. If a token leaks, there is no panic button unless a revocation list or key rotation exists.
9. Sensitive data in the payload
Since the payload is just base64url, putting a national ID, email, card number, or internal flags there is equivalent to publishing them. Anyone who sees the token (logs, proxy history, localStorage) reads everything.
How we exploit it in a pentest
The flow during an engagement is methodical and almost always fast.
Step 1 — Capture and decode. Grab a real token from the traffic in Burp Suite. The JWT Editor extension (or JOSEPH) decodes the header and payload right in the interface. Note alg, kid, and interesting claims (role, is_admin, tenant, exp, aud, iss).
Step 2 — Test alg: none. Edit the header to {"alg":"none"}, change role to admin, remove the signature (leave the trailing dot), and resend. Also try None and NONE. If the response authenticates, the flaw is proven.
Step 3 — Check whether the signature is verified. Keep the header and payload, but alter a single byte of the signature. If the server still accepts it, it verifies nothing — edit the claims at will.
Step 4 — Try algorithm confusion. If the token is RS256, obtain the public key (from /.well-known/jwks.json, from a TLS certificate, or by deriving it from two tokens with the jwt_tool tool). jwt_tool automates the attack (“key confusion” exploit mode, -X k):
# Algorithm confusion attack (RS256 -> HS256) using the public key
jwt_tool TOKEN -X k -pk public.pem
In Burp’s JWT Editor, the equivalent flow is to import the public key (the exact PEM) as a symmetric key and re-sign the token as HS256. Remember: what needs to match are the exact bytes of the PEM the server uses to verify.
Step 5 — Crack a weak HMAC secret. Only for HS256/HS* tokens, feed the token into a cracker. With hashcat (mode 16500, “JWT (JSON Web Token)”):
# Offline cracking of an HS256 JWT's HMAC secret
hashcat -a 0 -m 16500 token.jwt /usr/share/wordlists/rockyou.txt
Or with jwt_tool in wordlist crack mode (-C -d <dictionary>):
jwt_tool TOKEN -C -d rockyou.txt
Found the secret? Re-sign any payload — including {"role":"admin"} — with it.
Step 6 — Manipulate claims and abuse kid/jku. With any of the vectors above working, escalate: change sub to another account’s ID (IDOR via token), raise role, switch tenant. Test kid with path traversal (../../../../dev/null for an empty secret) and SQLi. Point jku at a Burp Collaborator — if the server makes the request, you have confirmed SSRF and likely forgery via your own JWKS.
Reporting tip: always attach the token pair (the original and the forged one) and the authenticated response showing the elevated privilege. “I managed to become admin” without the request/response is not evidence.
Summary for the report
- Impact: identity spoofing and privilege escalation — the attacker forges a valid token with
role: admin(or anothersub/tenant), taking over any account and fully bypassing the application’s authentication and authorization.- Severity: Critical for
alg:none/algorithm confusion — CVSS 3.1 = 9.8 with vectorAV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H. A crackable weak secret tends toward High/Critical; unvalidated claims, Medium/High depending on impact (adjust the vector if there are preconditions such as interaction or prior privilege).- Preconditions: the ability to intercept/obtain a valid token and replay requests; for algorithm confusion, access to the public key (often at
/.well-known/jwks.json) and a library/implementation without protection against using an asymmetric key as an HMAC secret; for a weak secret, just a captured token.- Suggested evidence: the original token and the forged token (decoded), the request carrying the manipulated token, the authenticated response displaying the elevated privilege, the altered
alg/kid/jku, and — in the case of a weak secret — the recovered secret and the wordlist/cracking time.
How to mitigate
The golden rule: never let the token choose how it will be verified. The server decides the algorithm and the key; the token’s alg field is untrusted input.
Pin an algorithm allowlist
The dangerous pattern, in any language, is letting verification derive the algorithm from the token’s header (without an allowlist) — this is what opens the door to alg:none and algorithm confusion. Libraries vary in how they handle this:
# DANGEROUS (pseudocode / libs without a mandatory allowlist):
# verifying the token using the "alg" that came in the header itself.
# In PyJWT 2.x this is NOT possible: jwt.decode() REQUIRES the
# algorithms argument and raises DecodeError without it — a good default.
# But homegrown implementations and old libs often trust the header.
# NEVER write code that picks the verifier based on alg.
# CORRECT — the server enforces the algorithm; anything off-list is rejected
payload = jwt.decode(
token,
key=PUBLIC_KEY, # RS256 public key
algorithms=["RS256"], # explicit, single ALLOWLIST
audience="api.intruderlabs", # validates aud
issuer="https://auth.intruderlabs", # validates iss
options={
"require": ["exp", "iat", "aud", "iss"],
"verify_signature": True,
"verify_exp": True,
"verify_aud": True,
"verify_iss": True,
},
)
With algorithms=["RS256"], a token forged with alg: HS256 is rejected before any cross-verification attempt — algorithm confusion dies here, as does alg: none. In other stacks, the equivalent is: golang-jwt with jwt.WithValidMethods([...]) (and checking the method type in the Keyfunc); jose/jsonwebtoken (Node) passing { algorithms: ['RS256'] }; jjwt (Java) pinning the expected algorithm.
Strong secret or proper key management
For HMAC, use a random secret of at least 256 bits (32 bytes), never a tutorial value:
import secrets
SECRET = secrets.token_bytes(32) # 256 bits of real randomness
For asymmetric (RS256/ES256), keep the private key in a vault (KMS/HSM/secret manager), publish only the public key via JWKS, and rotate it periodically.
Treat kid, jku and x5u as hostile input
Never use kid to open files or to build a query. Map it against a fixed set of known keys:
KEYS = {"2024-key": PUBLIC_KEY_2024, "2025-key": PUBLIC_KEY_2025}
def resolve_key(kid: str):
key = KEYS.get(kid) # lookup in a closed dictionary
if key is None: # unknown kid = reject
raise InvalidKeyError("unrecognized kid")
return key
For jku/x5u, do not fetch URLs from the token. Configure the JWKS endpoint on the server (or use a strict host allowlist) and ignore whatever comes in the header.
Short TTL, refresh token and revocation
import time, secrets
# Short-lived access token + jti to enable revocation
now = int(time.time())
claims = {
"sub": user_id, "role": role,
"iat": now, "exp": now + 900, # 15 min
"aud": "api.intruderlabs", "iss": "https://auth.intruderlabs",
"jti": secrets.token_hex(16), # unique token ID
}
On logout (or compromise), add the jti to a short-lived denylist (in Redis, with TTL = the token’s remaining lifetime). The 15-minute access token limits the window; the refresh token, long-lived and stored server-side, can be revoked immediately.
Do not put secrets in the payload
Treat the payload as public. Store only identifiers (sub, role) and fetch sensitive data on the backend from them. If you need confidentiality in the token itself, use JWE, not JWS.
Prefer libraries that validate by default
Use maintained libraries that require algorithms and reject none by default (recent PyJWT — which also blocks an asymmetric key as an HMAC secret —, jose, jjwt, golang-jwt with WithValidMethods). Defense in depth: the algorithm allowlist, claim validation, and revocation are independent layers — none of them alone covers everything.
Mitigation checklist
- Algorithm allowlist pinned on the server (
algorithms=[...]); never trust the token’salg. - Explicitly reject
alg: noneand its capitalization variants. - Ensure the signature is actually verified (never
verify_signature: Falsein production). - Random HMAC secret of ≥256 bits; asymmetric keys in KMS/HSM with rotation.
- Validate
exp,nbf,audandisson every verification (requirethe essential claims). -
kidresolved against a closed set of keys — no path traversal, no SQL. - Ignore the token’s
jku/x5u; JWKS configured server-side or a strict host allowlist. - Short TTL on the access token + a revocable refresh token + per-
jtidenylist on logout. - No sensitive data in the payload; use JWE if you need confidentiality.
- A maintained library that validates by default; test for regressions with
jwt_tool/hashcatin the pipeline.
JWT is not insecure — it is literal. It does exactly what the code tells it to, including trusting a token that says “do not verify me.” The difference between a robust credential and an editable form is a single decision: who defines the algorithm and the key, the server or the attacker. Pin that decision on the server, and the rest of the token becomes just data you already knew how to check.