JWKS, Key Rotation, and JWT Validation Pitfalls

JWKS, Key Rotation, and JWT Validation Pitfalls - article cover image Web Exploitation
Time it takes to read this article 6 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

A JSON Web Token’s security rests entirely on signature verification: the recipient must independently determine which key and algorithm to check the signature against, and that decision must not be influenced by the token itself. A JWKS (JSON Web Key Set) endpoint publishes an issuer’s current public keys so relying parties can fetch them and verify tokens without a pre-shared secret, and the JWT header’s kid (key ID) field is meant to tell the verifier *which* published key was used to sign a given token. The moment a verifier lets header fields — kid, jku, x5u, or an embedded jwk — influence *how* verification happens rather than merely *which pre-trusted key* to use, the token is effectively allowed to vouch for its own authenticity.

This class of bug is old (alg: none and RS256/HS256 confusion date back years) but resurfaces constantly in new forms because JWKS and key-rotation logic is usually hand-rolled around a library that offers plenty of rope: algorithm lists that are too permissive, kid values used unsanitized as lookup keys, and JWKS URLs resolved from attacker-controlled header fields all reduce to the same root failure — trusting the token to describe its own verification.

Attack Prerequisites

Exploiting JWT/JWKS validation logic generally requires one of these conditions to hold in the verifier:

  • The accepted algorithm list is not pinned to a single algorithm — a verifier configured to accept both an asymmetric algorithm (RS256) and a symmetric one (HS256) is confusable if the RSA public key is available to the attacker (which it usually is, by design, via the JWKS endpoint).
  • kid is used to build a file path, cache key, or database query without sanitization, opening path traversal or injection in the key-lookup step.
  • jku/x5u (or an embedded jwk) in the token header is honored to fetch or supply the verification key, rather than the verifier always fetching from a single hardcoded, trusted JWKS URL.
  • alg: none is not explicitly rejected by the verifying library/configuration.

How It Works

A JWS is base64url(header).base64url(payload).base64url(signature), and the header is attacker-visible and attacker-modifiable before signature verification even begins — the whole point of the signature is to prove the header and payload weren’t tampered with *after* signing, but the verifier still has to read the header first to know what to check. If the verifier’s logic for choosing a key or algorithm is itself driven by header content, an attacker who wants to forge a token doesn’t need to guess a secret; they need only convince the verifier to check the signature against a key or algorithm they control.

The classic RS256-to-HS256 confusion exploits verifiers that accept both algorithm families through one code path: the application signs legitimate tokens with RS256 (private key held server-side, public key published via JWKS), but the verifier is configured with algorithms: ['RS256', 'HS256']. HS256 is a symmetric algorithm — verification uses the *same* key as signing. If an attacker signs a forged token with alg: HS256, using the *public* RSA key (a PEM string, which is public by design) as the HMAC secret, a verifier that honors the token’s own alg header will HMAC-verify it against that same public key string and accept it as valid — because nothing forced the verifier to use RS256 semantics just because the real keys are RSA.

kid-driven attacks work similarly but target the key lookup rather than the algorithm: if kid selects a file path (keys/<kid>.pem) or a database row without validation, an attacker can supply a kid like ../../../../dev/null to point the lookup at a predictable, empty file, then sign an HS256 forgery using the empty string as the secret. If the verifier instead trusts a jku (JWK Set URL) or x5u header taken from the token to decide *where to fetch keys from at all*, the attacker simply hosts their own JWKS containing a keypair they generated, points jku at it, and signs the forged token with their own private key — the verifier obediently fetches and trusts attacker-hosted keys.

Vulnerable Code / Configuration

A verifier that resolves both the JWKS source and the specific key entirely from the untrusted token header, while also accepting two algorithm families:

const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

// VULNERABLE: the JWKS URL is taken from the token's own header ('jku'),
// and 'kid' is passed straight through to the key-set lookup.
function getKey(header, callback) {
  const client = jwksClient({
    jwksUri: header.jku || 'https://auth.example/.well-known/jwks.json',
  });
  client.getSigningKey(header.kid, (err, key) => {
    callback(null, key.getPublicKey());
  });
}

// VULNERABLE: accepts both an asymmetric and a symmetric algorithm
jwt.verify(token, getKey, { algorithms: ['RS256', 'HS256'] }, (err, decoded) => {
  if (err) return res.sendStatus(401);
  req.user = decoded;   // trusted from here on
});
JavaScript

Two independent bugs are stacked here: header.jku lets an attacker redirect key resolution to a server they control (SSRF plus a forgeable trust root), and algorithms: ['RS256', 'HS256'] allows the RS256-to-HS256 confusion described above even if jku is later locked down.

Walkthrough / Exploitation

Start by decoding the target token’s header and payload with jwt_tool (ticarpi’s JWT Toolkit) or jwt.io offline mode, noting alg, kid, jku, x5u, and any embedded jwk:

python3 jwt_tool.py <token>
# inspect header for: alg, kid, jku, x5u, jwk
Bash

Test alg: none first — it’s the cheapest check and some deployments still haven’t disabled it explicitly:

python3 jwt_tool.py <token> -X a
# jwt_tool's 'alg:none' attack mode: strips the signature, sets alg to none/None/nOnE
Bash

For RS256-to-HS256 confusion, fetch the application’s real public key from its JWKS endpoint (or an embedded certificate), then sign a forged token using that exact PEM (including its literal newlines) as the HMAC-SHA256 secret:

curl -s https://auth.example/.well-known/jwks.json -o jwks.json
# convert the matching JWK to PEM, then:
python3 jwt_tool.py <token> -S hs256 -k rsa_public.pem
# jwt_tool signs a new token with your modified claims (e.g. role: admin),
# HMAC'd using the RSA public key string as the shared secret
Bash

For jku/embedded-jwk attacks, generate your own RSA keypair, host a JWKS document containing your public key at an attacker-controlled URL, then craft a token whose header sets jku to that URL (or embeds the jwk directly) and is signed with your matching private key. Burp’s JWT Editor extension automates exactly this workflow — it has dedicated actions for embedding an attacker key, pointing jku/x5u at a hosted JWKS, and running the RS256/HS256 confusion attack, alongside a signing/verification tab for building the forged signature manually.

Note: An embedded jwk header is a distinct, even more direct variant: the token carries its own public key material inline in the header. If the verifier extracts and trusts that embedded key to check the signature instead of comparing it against a pinned, trusted key set, the attacker can sign with a key they generated and embed the matching public key in the same token — verification then trivially succeeds against a key the attacker chose.

Opsec / Testing tip: Key rotation gaps are worth checking even when signing is solid: if a verifier caches a JWKS response with no TTL or refresh logic, it may keep accepting tokens signed with a key that was supposed to be revoked, well past its intended rotation window — ask for the rotation schedule and test tokens signed just before and after a scheduled rotation.

Detection and Defense

Validation logic should never let the token describe how it should be verified:

  • Pin the accepted algorithm to exactly one value per verifier — never accept both an asymmetric and a symmetric algorithm in the same code path, and always explicitly exclude none.
  • Never resolve the verification key or JWKS source from the token’s own kid/jku/x5u headers. Fetch JWKS only from a single, hardcoded, allowlisted URL/host that the application controls.
  • Reject or ignore an embedded jwk header unless the application was explicitly designed to trust self-contained keys, and even then pin it against a known allowlist.
  • Validate kid against a strict format (e.g. a UUID regex) before using it for any lookup, and never build a file path or raw query directly from it.
  • Cache JWKS with a bounded TTL and a defined rotation strategy — on a kid miss, refresh from the trusted source rather than falling back to attacker-influenced resolution, and expire cached keys that have been rotated out.
  • Keep JWT libraries current — older defaults in several popular libraries accepted none or mixed algorithm families unless explicitly restricted; recent versions require the caller to opt in to an explicit algorithm list.

Real-World Impact

Algorithm-confusion and header-driven key resolution issues are extensively documented in the JWT security research from Auth0 and PortSwigger’s JWT Editor team, and continue to surface in bug-bounty reports against SSO integrations and API gateways that hand-roll JWKS handling rather than using a hardened, well-maintained library with sane defaults. Because a successful forgery is usually a direct authentication bypass, these findings are consistently rated critical wherever they appear.

Conclusion

A JWT verifier’s entire job is to decide the key and algorithm *independently* of what the token itself claims — the instant kid, jku, x5u, or an embedded jwk is allowed to steer that decision, the token can vouch for itself. Pin one algorithm, fetch JWKS only from a fixed trusted URL, sanitize kid before any lookup, and treat key rotation as a security control that needs its own testing, not an implementation detail.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL