JWT Security: Algorithm Confusion, Key Leaks, and Safe Validation
How JWT auth fails in practice: alg-none, RS256-to-HS256 confusion, kid and jku injection, weak secrets, and a hardened validator you can copy in Node and Python.
JSON Web Tokens are so easy to issue that teams rarely stop to think about how easy they are to forge. A JWT is just three base64url segments separated by dots — a header, a payload, and a signature — and every part of that structure, including the algorithm the server should use to verify it, is visible and editable by the client holding the token. That transparency is the whole point of a stateless, self-contained token, and it’s also where almost every real-world JWT vulnerability comes from: developers trust fields inside the token to tell the verifier how to verify it.
This article covers the JWT attack classes you’ll actually run into during an assessment — alg: none, algorithm confusion between RS256 and HS256, kid/jku header injection, and weak HMAC secrets — followed by a validation implementation in Node and Python that closes each of them.
Anatomy of the Problem
Decode any JWT header and you’ll typically see something like:
{
"alg": "RS256",
"typ": "JWT",
"kid": "2026-04-key"
}
The header names the signing algorithm and, often, which key was used. Both of those are attacker-controlled input the moment the token leaves the server, because nothing cryptographically binds the header’s claims about itself until after verification succeeds — and verification is exactly the step libraries get wrong when they let the token dictate its own verification method.
Attack 1: alg: none
The JWT spec defines none as a legitimate (if bizarre) algorithm meaning “this token is unsigned.” Early JWT libraries implemented this literally: if the header said alg: none, they’d skip signature verification entirely.
An attacker takes a valid token, decodes it, and rewrites the header and payload to something like {"alg":"none","typ":"JWT"}.{"sub":"1","role":"admin"}. — note the trailing dot and empty signature segment — a syntactically valid JWT with no signature at all. A verifier that honors the alg field from the token is handing the attacker the choice of “please don’t check my signature.”
Modern libraries default to rejecting none, but this bug resurfaces constantly in custom verification code, older SDKs, and anywhere a developer wires up JWT handling “by hand” against a tutorial from several years ago.
Attack 2: RS256-to-HS256 Algorithm Confusion
This is the more dangerous, less obvious variant, and it exploits a structural difference between how RS256 and HS256 use their keys:
- RS256 is asymmetric: the server signs with a private RSA key and verifies with the corresponding public key. The public key is, by design, not secret — it might be published at a
/.well-known/jwks.jsonendpoint or embedded in client code. - HS256 is symmetric: the same secret is used to both sign and verify. Whatever value the verifier passes in as “the secret” is trusted completely.
If your verification code does something like jwt.verify(token, key, { algorithms: ["RS256", "HS256"] }) and blindly passes the same key variable regardless of which algorithm the token claims, an attacker can:
- Take the server’s public RSA key (freely available from JWKS).
- Craft a new token with
alg: HS256. - Sign it using HMAC-SHA256, with the RSA public key’s raw bytes as the HMAC secret.
The verifier, told to trust HS256, uses that same public key value as the HMAC secret to check the signature — and since the attacker signed with exactly that value, it validates. The attacker has just forged a token with any claims they want, using a key that was never meant to be secret.
const jwt = require("jsonwebtoken");
// VULNERABLE: algorithm comes from the token, key is reused across algorithm types
function verifyTokenUnsafe(token, publicKey) {
// No algorithms restriction — jsonwebtoken will honor whatever the token claims
return jwt.verify(token, publicKey);
}
// FIXED: algorithm is pinned server-side, never read from the token
function verifyTokenSafe(token, publicKey) {
return jwt.verify(token, publicKey, {
algorithms: ["RS256"], // exactly one algorithm, chosen by the server
audience: "api.example.com",
issuer: "https://auth.example.com/",
});
}
The fix is not “validate the key better.” It’s structural: the server decides the algorithm, once, out of band, and never negotiates it based on what the token claims.
Attack 3: kid and jku Header Injection
Many JWT implementations use the kid (key ID) header to look up which key to use for verification — convenient in a key-rotation setup, dangerous if the lookup isn’t sanitized.
{ "alg": "RS256", "kid": "../../../../dev/null" }
{ "alg": "RS256", "jku": "https://attacker.example/.well-known/jwks.json" }
If the verifier does something like readFileSync(\keys/${header.kid}.pem`), an attacker-controlled kid becomes a path traversal primitive. Point it at a file with predictable, attacker-influenced or empty content (/dev/null` on some systems reads as empty, which some crypto libraries treat as a valid all-zero key or accept as an HMAC secret of zero length), and the “key” the server ends up verifying against is one the attacker effectively chose.
A related header, jku (JWK Set URL), tells the verifier where to fetch the public key from over HTTP, as in the second example above. If the verifier fetches whatever URL is in jku without checking it against an allowlist, the attacker hosts their own JWKS document containing a key pair they control, and the server happily verifies the attacker’s forged token against the attacker’s own public key. This is SSRF and authentication bypass rolled into one header.
import jwt
# VULNERABLE: kid is used to build a filesystem path directly
def verify_unsafe(token):
header = jwt.get_unverified_header(token)
key_path = f"/etc/jwt-keys/{header['kid']}.pem"
with open(key_path, "rb") as f:
key = f.read()
return jwt.decode(token, key, algorithms=["RS256"])
# FIXED: kid is validated against a fixed allowlist, never used to build a path
TRUSTED_KEYS = {
"2026-04-key": open("/etc/jwt-keys/2026-04-key.pem", "rb").read(),
"2026-05-key": open("/etc/jwt-keys/2026-05-key.pem", "rb").read(),
}
def verify_safe(token):
header = jwt.get_unverified_header(token)
kid = header.get("kid")
if kid not in TRUSTED_KEYS:
raise ValueError(f"Unknown key id: {kid}")
return jwt.decode(
token,
TRUSTED_KEYS[kid],
algorithms=["RS256"],
audience="api.example.com",
issuer="https://auth.example.com/",
)
If you use jku at all, resolve it against a hardcoded, pinned allowlist of trusted JWKS hosts — never fetch an arbitrary URL supplied by the token itself.
Attack 4: Weak or Reused HMAC Secrets
HS256 tokens are only as strong as the shared secret. Short, guessable, or default secrets (secret, changeme, a project name) are crackable offline once you have one valid token, because the attacker can brute-force candidate secrets and check whether they reproduce the observed signature — no interaction with the server required.
# Offline HMAC secret cracking against a captured JWT (illustrative)
hashcat -m 16500 captured_token.txt wordlist.txt
Once the secret is recovered, the attacker signs arbitrary tokens forever, until the secret is rotated. Treat an HS256 signing secret with the same rigor as a database root password: generate it with a CSPRNG, store it in a secrets manager, and never let it double as an API key or fallback value elsewhere in the codebase.
# Generate a proper HS256 secret - 256 bits of entropy, base64-encoded
openssl rand -base64 32
Missing Claim Validation: aud, iss, exp
Even a token with a technically valid signature can be misused if the verifier doesn’t check who it was issued for and by, or whether it has expired.
exp(expiration) — nearly every library checks this by default, but confirm it explicitly in tests. A token minted for a short-lived session should not still work a week later because a custom decode path bypassed expiry checks.aud(audience) — without an audience check, a token issued for one API can be replayed against another API that trusts the same identity provider. This is a common cross-service token replay bug in microservice architectures sharing one auth provider.iss(issuer) — without an issuer check, a token from a lower-trust environment (staging, a partner’s tenant, a different application in the same IdP) may be accepted by a service that should only trust its own issuer.
| Claim | What skipping it enables | Fix |
|---|---|---|
exp | Tokens usable indefinitely after they should have expired | Reject if exp is missing or in the past; keep token lifetimes short |
aud | Cross-service replay: a token for Service A works against Service B | Always pass an explicit audience to the verify call, reject non-matching tokens |
iss | Tokens from an untrusted tenant/environment accepted as legitimate | Pin issuer to the exact expected value, not a substring match |
nbf | Pre-issued tokens usable before their intended activation time | Honor nbf if your issuer sets it; most libraries do this by default |
A Hardened Validator, End to End
Pulling the fixes together, here’s what a defensible verification path looks like on both stacks:
// Node.js / TypeScript - hardened JWT verification
import jwt, { JwtPayload } from "jsonwebtoken";
import jwksClient from "jwks-rsa";
const client = jwksClient({
jwksUri: "https://auth.example.com/.well-known/jwks.json", // pinned, not from the token
cache: true,
rateLimit: true,
});
function getSigningKey(kid: string): Promise<string> {
return new Promise((resolve, reject) => {
client.getSigningKey(kid, (err, key) => {
if (err) return reject(err);
resolve(key.getPublicKey());
});
});
}
export async function verifyAccessToken(token: string): Promise<JwtPayload> {
const decoded = jwt.decode(token, { complete: true });
if (!decoded || typeof decoded === "string") throw new Error("Malformed token");
const kid = decoded.header.kid;
if (!kid) throw new Error("Missing kid header");
const publicKey = await getSigningKey(kid); // looked up server-side, never trusted from a jku header
return jwt.verify(token, publicKey, {
algorithms: ["RS256"], // exactly one algorithm, hardcoded
audience: "api.example.com",
issuer: "https://auth.example.com/",
clockTolerance: 5, // seconds, to absorb clock skew only
}) as JwtPayload;
}
# Python - hardened JWT verification with PyJWT
import jwt
from jwt import PyJWKClient
jwks_client = PyJWKClient("https://auth.example.com/.well-known/jwks.json")
def verify_access_token(token: str) -> dict:
signing_key = jwks_client.get_signing_key_from_jwt(token) # kid resolved via pinned JWKS host
return jwt.decode(
token,
signing_key.key,
algorithms=["RS256"], # exactly one algorithm, hardcoded
audience="api.example.com",
issuer="https://auth.example.com/",
leeway=5, # seconds of clock skew tolerance
)
Both examples share the same structural properties: the algorithm is a server-side constant, the key lookup goes through a library-managed, pinned JWKS client rather than a raw file path or attacker-supplied URL, and audience/issuer are checked explicitly rather than left to library defaults.
A Pentest Checklist for JWT-Based Auth
- Does the verifier accept
alg: none, or a downgraded algorithm not intended for that endpoint? - Does changing
algfromRS256toHS256and signing with the public key (or JWKS endpoint content) produce a token the server accepts? - Is
kidused to build a filesystem path or database query without an allowlist check? - Is
jkuorx5uhonored without validating the host against a pinned allowlist? - Is the HS256 secret (if used) long, random, and rotated — and does the token verify against common/weak secrets offline?
- Are
aud,iss, andexpall explicitly checked, with an exact-match issuer and audience rather than substring matching? - Do expired or not-yet-valid tokens actually get rejected in practice, not just in the library’s documentation?
Key Takeaways
- Never let the token tell the verifier which algorithm to use — pin the algorithm server-side and reject anything else.
- RS256-to-HS256 confusion happens when a public verification key is accidentally reused as an HMAC secret; keep asymmetric and symmetric key material in entirely separate code paths.
- Treat
kidas untrusted input: validate it against a fixed allowlist of known key IDs, never interpolate it into a file path or query. - Treat
jku/x5uas untrusted URLs: resolve keys only from a hardcoded, pinned JWKS host, never one named inside the token. - Generate HS256 secrets with a CSPRNG (32+ random bytes) and store them in a secrets manager, not a config file or environment default.
- Explicitly validate
aud,iss, andexpon every verification call; don’t rely on a library’s implicit defaults without testing them. - When in doubt, prefer asymmetric algorithms (RS256/ES256) for any token that crosses a trust boundary between services or organizations.