JWT libraries keep trusting the key an attacker puts in the header
In March 2026 Authlib and the Dart jose package both shipped fixes for the same flaw node-jose fixed in 2018: a verifier that accepted the public key embedded in a token’s own jwk header. A deep dive into how jwk, jku, x5u, x5c and kid let an attacker steer which key verifies a JWT, the quieter fall-through that made the Authlib bug easy to miss, and the key-resolution rules that shut all five down.
Methodology: Phase 05 · Authentication and session testing
Two lines in a Python JOSE library were enough, in the wrong application, to let anyone mint a token that verifies:
# authlib/jose/rfc7515/jws.py, before 1.6.9 (condition quoted in the advisory)
elif key is None and "jwk" in header:
# ...the key used for verification comes from the token headerThat fallback in Authlib's JsonWebSignature._prepare_algorithm_key() meant that when an application asked to verify a JWS without resolving a key, the library quietly used the public key sitting in the token's own jwk header. An attacker generates a key pair, writes whatever claims they like, signs with their private key, embeds the public half, and the signature checks out. Authlib fixed it in 1.6.9 as CVE-2026-27962, published on 16 March 2026 with a CVSS 3.1 score of 9.1.
At the end of that month the Dart jose package published CVE-2026-34240: its key selection “could treat header-provided jwk as a verification candidate” even when that key was not in the application's key store. And eight years before both, Cisco's node-jose fixed CVE-2018-0114, the same flaw, in version 0.11.0. Three ecosystems, one mistake: letting the token decide which key verifies it.
Embedded keys are only one of five JOSE header parameters that point at key material. This piece walks through each of them as an attack surface, shows why the Authlib variant was easier to ship than it looks, and ends with key-resolution rules that close the whole class rather than one header at a time.
Five header parameters that point at a key
A JWS header is attacker-controlled JSON. It is base64url-encoded, not encrypted, and it is read before the signature is checked, because the verifier needs it to know how to check the signature. RFC 7515 defines five parameters that tell the verifier where the key is:
jwk: the public key itself, embedded in the header.jku: a URL for a JWK Set that contains the key.x5c: an X.509 certificate chain, embedded in the header.x5u: a URL for an X.509 certificate or chain.kid: an identifier the verifier uses to look up a key it already holds.
The RFC is clear that none of these makes a key trustworthy. It calls kid “a hint indicating which key was used,” and its security considerations (section 10.3) say the key management technique “must authenticate the origin of the key; otherwise, it is unknown what party signed the message.” A signature that verifies under a key the attacker chose tells you only that the attacker signed it.
Embedded jwk: a token that vouches for itself
The forged header for an embedded-key attack looks like this, with the attacker's own RSA modulus in n:
{
"alg": "RS256",
"typ": "JWT",
"jwk": { "kty": "RSA", "e": "AQAB", "n": "<attacker's modulus>" }
}PortSwigger's jwk injection lab models the classic case, a server that “fails to check whether the provided key came from a trusted source”: change sub to administrator, sign with a fresh key, embed it, and the admin panel opens. That is the node-jose bug and the Dart bug in one request.
The Authlib case is subtler and worth studying, because the advisory describes two triggers. The obvious one is calling deserialize_compact(), deserialize_json() or deserialize() with key=None, which few production apps do on purpose. The dangerous one is passing a key-resolver function that returns None, for example a JWKS lookup that finds nothing for an unknown or rotated kid. Instead of failing, the library fell through to the header's jwk. So an application that did look keys up correctly for every legitimate token could still be bypassed by a token carrying a kid it had never seen plus an embedded key. The developer wrote a lookup; the library turned the lookup's miss into an attacker-supplied key.
Not every library treats embedded keys as an accident. Panva's jose for JavaScript ships an EmbeddedJWK resolver that you must pass deliberately, documented as the way to “opt-in to verify signatures with a public key embedded in the token's jwk” header. There are real protocols where that is correct (DPoP proofs, for instance, carry the client's key and bind it elsewhere), but in those protocols trust comes from that binding, not from the signature alone. For a session or access token issued by your own identity provider, an embedded key has no legitimate use.
jku and x5u: the verifier fetches the attacker's keys
URL-valued headers move the attacker's key one hop away. The token says “my key is in the JWK Set at this URL,” and a verifier that follows it downloads the attacker's key set and validates against it. PortSwigger's jku injection lab uses a server that “fails to check whether the provided URL belongs to a trusted domain before fetching the key”: host a JWK Set on an exploit server, point jku at it, set kid to match, sign, done.
RFC 7515 requires that the jku and x5u fetches use integrity-protected transport and validate the server's identity, but TLS only tells you that you reached the host named in the URL, and the attacker picked the URL. RFC 8725, the JWT best-current-practice document, also flags the second problem: “blindly following” a jku or x5u header is a server-side request forgery primitive, reached before any authentication, and it recommends matching the URL against an allowlist and sending no cookies with the request. If the fetch goes through a shared HTTP client, everything in our piece on SSRF filters that resolve DNS twice applies here too.
Allowlists are where this usually goes wrong in practice. Three patterns to look for when reviewing or testing one:
- String matching instead of parsing. A check that the URL starts with or contains the trusted host can be satisfied by a URL that resolves somewhere else. Compare the scheme, host and port of a parsed URL for exact equality.
- Open redirects on the trusted host. If the HTTP client follows redirects, any redirect endpoint on the allowlisted domain hands the fetch to the attacker.
- Attacker-writable content on the trusted host. A file-upload bucket, a user profile field or a paste feature on the same origin can serve a JWK Set the attacker wrote.
The cleanest fix removes the question entirely: the verifier never reads a URL from the token. It fetches keys from one JWKS URL that lives in configuration, typically the jwks_uri from your identity provider's metadata.
x5c: a certificate chain that nobody checked
An embedded certificate chain is the same attack as an embedded jwk, wearing a certificate. RFC 7515 says the recipient “MUST validate the certificate chain according to RFC 5280” and treat any failure as invalid. A verifier that pulls the public key out of the first certificate in x5c without building a chain to a trust anchor it controls will accept a self-signed certificate the attacker generated a minute ago. If your system really does need certificate-based tokens, chain validation against a pinned, application-specific root (not the operating system's public CA store) plus a check of the subject you expect is the minimum; otherwise reject x5c outright.
kid: when a key lookup becomes an injection sink
kid is the one key header most deployments need, because it is how a verifier picks the right key during rotation. It cannot introduce a new key by itself. What it can do is reach whatever code turns the identifier into a key, and that code often treats it as a trusted string. RFC 8725 says it plainly: applications “should ensure that this does not create SQL or LDAP injection vulnerabilities.” Two shapes show up repeatedly:
// Illustrative only: kid used as a file path and as SQL const secret = fs.readFileSync(path.join(KEY_DIR, header.kid)); const row = await db.query( "SELECT secret FROM signing_keys WHERE kid = '" + header.kid + "'" );
The file-path version is the subject of PortSwigger's kid path traversal lab. With an HMAC algorithm such as HS256, the verifier uses the file's contents as the shared secret, so the attacker sets kid to something like ../../../../../../../dev/null and signs with an empty-equivalent secret (the lab signs with a null byte). As the lab notes, any file with predictable contents works; on a real server, a static asset or a well-known system file is enough. The SQL version is classic injection with a twist: a UNION that returns a string the attacker chose turns the database into the attacker's key store.
Both depend on the verifier accepting a symmetric algorithm from the header, which is why they combine so well with the algorithm-confusion bugs covered in our OpenID Connect relying-party piece. RFC 8725 section 3.1 closes that door from the other side: “each key MUST be used with exactly one algorithm.” If the key a kid resolves to is an RSA public key bound to RS256, a header claiming HS256 fails before any secret is computed.
Testing a verifier's key resolution
These checks belong on any authorized assessment of an API that accepts JWTs, and they are cheap because each one is a single request with a re-signed token. Burp's JWT Editor extension, used in all three PortSwigger labs, automates the signing.
- Embedded key. Change a claim, sign with a fresh RSA key, embed it as
jwk. Then repeat with akidthe server has never issued, which is the Authlib-style fall-through. - Remote key. Point
jku(andx5u) at a collaborator URL first. An inbound fetch tells you the header is followed before you host any keys; then serve a JWK Set and sign with it. - Self-signed chain. Embed a fresh self-signed certificate in
x5c. - kid sinks. Watch for differences in errors or timing between a valid
kid, an unknown one, and values containing../or a quote. A traversal to a predictable file plus anHS256signature with the matching secret is the end-to-end test.
Read the result carefully. A 401 from a gateway does not mean the service behind it rejects forged tokens, and a 200 on an endpoint that ignores identity means nothing; test against a request whose response clearly depends on who the token says you are. Keep traversal and injection payloads read-only, and do this only within an authorized scope. The first three checks are mechanical and suit automated tooling well; the kid sinks need someone, or something, to infer what the lookup does from how it fails.
Pinning key selection in code
The rule that closes all five headers at once: the application decides which keys are trusted, and the header may only choose among them. With Panva's jose, a verifier for tokens from a single issuer looks like this:
import { createRemoteJWKSet, jwtVerify } from "jose";
// The JWKS URL comes from config, never from the token.
const JWKS = createRemoteJWKSet(
new URL("https://login.example.com/.well-known/jwks.json")
);
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://login.example.com/",
audience: "api://orders",
algorithms: ["RS256"],
});Per its documentation, createRemoteJWKSet selects a key from that fixed set by the header's alg and kid, and rate-limits refetches for unknown keys with a cooldown, so a stream of made-up kid values cannot turn your verifier into a request amplifier. Whatever your stack, the same properties are what to look for:
- Keys come from configuration or a configured JWKS URL.
jwk,jku,x5uandx5care rejected unless a specific protocol requires one, and then only with that protocol's binding checks. kidis an exact-match lookup into an in-memory set. It never reaches a file path, a query or a shell.- A miss is an error. Write the resolver so that “no trusted key” raises, rather than returning a value the library might interpret.
- Each key is bound to one algorithm, and the allowed algorithms are pinned in code, not read from the header.
- Library versions are current: Authlib 1.6.9 or later, Dart
jose0.3.5+1 or later, andnode-jose0.11.0 or later.
For Authlib, or any library that accepts a key-resolver callback, a resolver that fails closed is a few lines (a sketch; adapt the key objects to your own store):
# Sketch: resolve only known keys, and raise on a miss
def resolve_key(header, payload):
key = TRUSTED_KEYS.get(header.get("kid"))
if key is None:
raise ValueError("unknown or missing kid")
if header.get("alg") != key.alg:
raise ValueError("algorithm does not match key")
return key.materialWhy this bug keeps coming back
The 2018 and 2026 fixes share a root cause that has nothing to do with cryptography. JOSE headers are convenient: they let one verifier handle rotation, multiple issuers and protocols that carry their own keys. Library authors expose that convenience by default and applications inherit it without noticing, until a code path nobody tested (a lookup that returns nothing, a header nobody expected) hands control to whoever wrote the token. The signature math was correct in every case above. What failed was the answer to an earlier question: whose key is this?
That makes key resolution the part of your JWT handling worth reading line by line, and the part worth testing with forged tokens rather than assuming. A verifier that only ever looks up keys it already trusts is immune to all five headers, whatever the next library bug turns out to be.
Ready to see it prove a bug?
Start scanning on Cloud →