JWT Security Pitfalls: Algorithm Confusion, Header Injection, and the Claims Everyone Forgets to Check

JWTs are everywhere because they solve an ugly problem: how does a stateless service know who’s calling without a database round trip on every request? The token carries signed claims, the service verifies a signature with a public key, done. But the same property that makes JWTs convenient — the server trusts a self-describing blob — makes every validation mistake a potential authentication bypass. The history of JWT vulnerabilities is largely a history of servers trusting parts of the token that the attacker controls: the algorithm name in the header, the key material embedded in the header, the issuer string in the payload.

The good news is that the attack surface is finite and well mapped. The JWT Best Current Practices (RFC 8725) codifies the defensive rules, and every modern library makes them implementable. This post walks through the attacks that still show up in real bug bounties and real CVEs, and the verification code patterns that close each one off.

First, what an attacker controls

A JWT is three base64url-encoded segments: header, payload, signature. Before verification completes, every byte of the token is attacker-controlled input — including the header that tells your library how to verify and which key to use. Treat the header like a query parameter. The entire discipline of JWT verification follows from that one sentence.

{
  "alg": "RS256",
  "kid": "2026-09-key-1",
  "typ": "JWT"
}

The two fields worth attacking are alg (which algorithm verifies the signature) and kid (which key). Both are read by your verification code before the signature check has proven anything.

Attack 1: the none algorithm

The JWS spec includes an “unsecured” mode, alg: none, where the signature is empty. It exists for cases where integrity is guaranteed elsewhere, and it has no business in a web application. The classic exploit: take a valid token, strip the signature, set alg to none (trying the many case and whitespace variants some parsers have accepted, like NoNe), and change the payload to {"role": "admin"}. If the verification library accepts unsigned tokens — several did by default in the mid-2010s, and misconfigured deployments still turn up — the forged token verifies.

The fix is an algorithm allowlist at verification time. Never a denylist: checking for the literal string "none" misses variants and misses future bad algorithms. State what you expect, reject everything else.

Attack 2: algorithm confusion (RS256 to HS256)

This is the most elegant JWT attack, and it works against code that validates signatures but derives behavior from the header. The setup: the server issues tokens signed with RS256 and verifies with a public key. The attacker downloads the public key — it’s public, often literally served at a JWKS endpoint — flips the alg header to HS256, and signs the forged token with HMAC-SHA256 using the public key’s bytes as the HMAC secret. If the verification code reads alg from the header and dispatches to the matching verify function using the same key material, the HMAC “verifies” — because HMAC doesn’t care that its secret looks like an RSA public key.

// VULNERABLE: algorithm comes from the token itself.
func verifyBad(tokenString string, key any) error {
    _, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
        return key, nil // alg header picks the verification path
    })
    return err
}

// SAFE: pin the expected algorithm; the key must match it.
func verifyGood(tokenString string, publicKey *rsa.PublicKey) error {
    _, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
        if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
        }
        return publicKey, nil
    }, jwt.WithValidMethods([]string{"RS256"}))
    return err
}

The structural fix goes deeper than the code check: bind each key to one algorithm. If your JWKS entries or key records carry an alg field, enforce it at lookup time so an HS256 header physically cannot select an RSA key. RFC 8725 requires exactly this — algorithm verification must happen against an allowlist, and the key’s intended algorithm must be respected. Every major library now supports it: WithValidMethods in golang-jwt, the algorithms parameter in PyJWT’s decode, the algorithms option in node-jsonwebtoken (which made it mandatory after version 8’s CVEs).

Attack 3: header parameter injection (jwk, jku, kid)

If your server selects a verification key based on header fields, the header can feed you an attacker-chosen key:

  • jwk — the header embeds a full JSON Web Key. Vulnerable servers happily verify the token using the very key embedded in it, which means anyone can sign anything. Never accept keys from the token.
  • jku — the header carries a URL pointing to a key set. A vulnerable server fetches and trusts it — an attacker hosts their own JWKS with their own key pair. Fetch keys only from your own configured, allowlisted endpoints over TLS.
  • kid — selects a key by ID from your keystore. Two dangers: path traversal (kid: "../../dev/null" style injection against servers that resolve it as a file path) and SQL injection where the kid is interpolated into a database query. Look keys up via a safe parameterized store keyed by exact string match, and reject unknown kid values.

The unified principle: the server’s key material comes from server-side configuration, never from the token. The token can name a key; it can never supply one.

Attack 4: weak HMAC secrets

HS256 secrets are strings, and strings get chosen badly. Default secrets from tutorials (“your-256-bit-secret” from jwt.io’s UI being the most infamous), placeholder values, and short secrets are all brute-forceable offline — a leaked or observed token lets an attacker test candidate secrets at line rate with hashcat. Once the secret is recovered, token forgery is trivial and completely undetectable, since every forged token carries a valid signature.

HS256 remains fine for a single service that issues and verifies its own tokens, but the secret must be long random bytes (256 bits), stored in a secrets manager, and rotated. The moment a second service needs to verify tokens, switch to an asymmetric algorithm — otherwise every verifier holds the power to mint tokens.

Claim validation: the signature proves nothing by itself

A valid signature on a stolen, expired, or wrong-audience token means exactly nothing until the claims are checked. Signature verification and claim validation are separate steps, and skipping the second is a complete vulnerability class of its own:

  • exp and nbf — verify with clock skew tolerance (a couple of minutes). Libraries reject expired tokens by default, but hand-rolled verification frequently forgets.
  • aud — a token for your internal admin API is valid in the cryptographic sense even when presented to the customer-facing API. Enforce the expected audience on every endpoint group; cross-service token reuse turns one leak into full lateral movement.
  • iss — when you accept tokens from multiple identity providers, pin the expected issuer per configuration, or a token from a dev tenant verifies in prod.
  • Missing claims — a token without exp never expires. Reject tokens that omit claims you require rather than defaulting them to “valid.”

Use sub as an opaque identifier and never authorize on client-controlled claims like role without a server-side source of truth for anything sensitive. Embedding role: admin in a long-lived token means demoting a compromised admin account requires waiting out the token lifetime — or a revocation mechanism, which brings us to the hard part.

Revocation and the stateless trade-off

JWTs are stateless, which is exactly why revocation is awkward: there is no server-side session record to delete. “Just blacklist the token” reintroduces a database lookup per request — the thing you adopted JWTs to avoid. The workable patterns, in ascending complexity:

  • Short-lived access tokens + rotating refresh tokens — the default answer. Access tokens live 5-15 minutes; refresh tokens are stored server-side, rotated on every use, and revocable. Logout revokes the refresh token; stale access tokens die within minutes on their own.
  • Versioned claims — put a session version or password-change timestamp in the token and compare against the user record only on sensitive operations, not on every request.
  • Denylist with TTL — a cache keyed by jti with an entry that expires when the token would have anyway. Acceptable when you only need “kill this one token now”; it costs the lookup you were avoiding, but only when configured selectively.
  • Pushed revocation — pub/sub revocation events to all verifier nodes, with verifiers keeping an in-memory denylist. More moving parts, no per-request lookup.

Note the honest conclusion hiding in this list: most production JWT deployments are not fully stateless. They’ve just moved the state to a less frequently consulted place. Design for the queries you’ll actually need — usually “revoke this user’s sessions” — and keep the hot path clean.

Token handling outside the crypto

Plenty of JWT incidents never touch the signature. Tokens in localStorage are readable by any XSS payload — for browser apps, HttpOnly, Secure, SameSite cookies are the safer default, with the CSRF trade-offs that come with them. Tokens in URLs leak via referrer headers, browser history, and server logs. And information leakage cuts the other way too: a JWT’s payload is merely encoded, not encrypted — anyone who captures the token can read every claim. Never put secrets, PII you don’t need, or anything sensitive in the payload. If confidentiality matters, JWE (encryption) exists; most systems don’t need it once the payload holds only identifiers and expiry.

A verification checklist

  • Pin the algorithm: allowlist at verification time, and bind each key to exactly one algorithm.
  • Never accept keys from the token (jwk, jku); treat kid as untrusted input for a parameterized lookup.
  • HS256 only within one service boundary and with a 256-bit random secret; otherwise RS256/ES256/EdDSA.
  • Validate exp, nbf, iss, and aud on every request; reject missing required claims.
  • Short-lived access tokens, rotating server-side refresh tokens, revocation on logout.
  • Transport tokens in HttpOnly cookies or authorization headers — never URLs or localStorage when XSS is in your threat model.
  • Treat the payload as public: identifiers and timestamps only.

None of this argues against JWTs. It argues for treating token verification as what it is: parsing attacker-controlled input and deciding whether to trust it. Pin the algorithm, own the keys, check the claims, keep tokens short-lived — and the entire exotic attack catalog above collapses into “the library handles it, correctly configured.”

Leave a Reply

Your email address will not be published. Required fields are marked *