Skip to main content
root/it-toolkit/catching-jwt-algorithm-confusion-attacks

Catching JWT Algorithm Confusion Attacks

A technical breakdown of building a diagnostic JWT validator that detects algorithm confusion, key-type mismatches, and header injection exploits.
Catching JWT Algorithm Confusion Attacks
09/07/2026|1 min read|

Most JWT libraries trust the token to tell them how to verify itself. That single design flaw is the root cause of JWT algorithm confusion, a class of vulnerability where an attacker rewrites the alg header of a signed token and forces the verifier down a completely different cryptographic path than the issuer intended. If you are building a diagnostic or debugging utility for the IT Toolkit category, a naive “paste your token, see the payload” tool is worse than useless — it can actively teach developers to trust unverified header fields. This article walks through building a diagnostic JWT validator that is architecturally hardened against algorithm confusion, key-type substitution, and header injection, rather than one that merely decodes base64.

#The Bottleneck: Verification Logic That Trusts the Attacker

A standard JWT has three segments: header, payload, and signature. The header declares alg and often a kid (key ID). The fundamental mistake in countless verification implementations is passing the token’s own declared algorithm into the cryptographic verify call. Consider a server issuing tokens signed with RS256 (asymmetric — private key signs, public key verifies). If the verification code does something like jwt.verify(token, publicKey) without pinning the expected algorithm, an attacker can:

  • Change alg to HS256 (symmetric).
  • Sign the token using the server’s public key as the HMAC secret — public keys are, by definition, not secret.
  • Submit the forged token; the library reads HS256 from the header, treats the RSA public key bytes as an HMAC secret, and the signature validates.

A second variant targets the none algorithm, where some early library implementations skipped signature verification entirely if alg: none was present. A third, more modern variant abuses the jwk or x5c header fields, where the attacker embeds their own public key directly inside the token header and the verifier blindly uses it to check the signature — effectively asking the fox to guard the henhouse.

Any diagnostic tool worth shipping needs to actively surface these conditions rather than silently decoding the token and displaying a green tick.

#Architectural Breakdown

The correct architecture separates three concerns that most tutorials collapse into one function call: untrusted parsing, key resolution, and algorithm-pinned verification. These must execute in strict order, and the output of step one must never influence step three.

#1. Untrusted Header Parsing

The header is read only to extract kid (for key lookup) and to log the claimed alg for diagnostic purposes. It is never used to select the verification algorithm at runtime. This is the single most important architectural decision in the entire tool.

#2. Key Resolution Isolated From Header Content

The verifier maintains a static, out-of-band mapping of issuer → expected algorithm → key material. If a token’s kid doesn’t exist in the trusted key store, verification fails immediately — the tool must never fetch a key based on a URL, jwk, or x5c field embedded in the token itself. This is the same principle behind architectural patterns that enforce strict boundaries between untrusted input and privileged decision-making — you don’t let the request body decide which database to query.

#3. Algorithm-Pinned Verification

Verification is called with an explicit allowlist, e.g. ["RS256"], never derived from the token. If the token’s declared alg doesn’t match the allowlist, the tool rejects it before any cryptographic operation runs. This single guard eliminates the entire algorithm confusion class.

JWT algorithm confusion

#Implementation Logic

Building the diagnostic utility follows this sequence:

  • Split the JWT into header/payload/signature without deserialising cryptographic trust.
  • Base64url-decode the header and payload for display only — flag this output as “unverified” in the UI/CLI until step 5 completes.
  • Look up the issuer’s configured algorithm and key set from a local trust store (JSON config, not the token).
  • Reject immediately if the token’s alg is none or absent from the configured allowlist.
  • Run verification with the pinned algorithm and resolved key; only then mark claims as trusted.
  • Independently validate exp, nbf, aud, and iss claims — signature validity does not imply claim validity.

#Trust Store Configuration

The tool should never accept ad-hoc key material typed into a text box for anything beyond an explicitly labelled “sandbox” mode. Production-style diagnostics load a static config:

1{
2  "issuers": {
3    "https://auth.kbytech.internal/": {
4      "allowed_algs": ["RS256"],
5      "jwks_uri": "https://auth.kbytech.internal/.well-known/jwks.json",
6      "jwks_cache_ttl_seconds": 900,
7      "require_kid": true
8    },
9    "https://legacy-sso.kbytech.internal/": {
10      "allowed_algs": ["HS256"],
11      "shared_secret_ref": "vault:secret/data/jwt/legacy-hmac"
12    }
13  }
14}

Note the deliberate separation: RS256 issuers resolve keys via a pinned JWKS URI (fetched out-of-band, cached, never taken from the token), while the legacy HMAC issuer resolves its secret from a vault reference — never from anything the client supplies.

#Verification Core (Python)

Using pyjwt, the critical fix is passing algorithms= explicitly and never trusting a header-derived value:

1import jwt
2from jwt import PyJWKClient
3
4def verify_token(token: str, issuer_config: dict) -> dict:
5    unverified_header = jwt.get_unverified_header(token)
6    claimed_alg = unverified_header.get("alg")
7
8    # Diagnostic flag only — never used to select verification path
9    if claimed_alg not in issuer_config["allowed_algs"]:
10        raise ValueError(
11            f"Rejected: token declares alg={claimed_alg}, "
12            f"issuer permits {issuer_config['allowed_algs']}"
13        )
14
15    if claimed_alg.startswith("HS") and "shared_secret_ref" in issuer_config:
16        key = resolve_secret(issuer_config["shared_secret_ref"])
17    else:
18        jwk_client = PyJWKClient(issuer_config["jwks_uri"])
19        signing_key = jwk_client.get_signing_key_from_jwt(token)
20        key = signing_key.key
21
22    decoded = jwt.decode(
23        token,
24        key,
25        algorithms=issuer_config["allowed_algs"],  # explicit allowlist, not header-derived
26        options={"require": ["exp", "iat"]},
27    )
28    return decoded

The algorithms= argument is the load-bearing wall here. Even if an attacker forges alg: HS256 on an RS256 token and signs it with the public key, pyjwt will refuse because HS256 is not in the issuer’s allowlist for that trust store entry.

#CLI Diagnostic Output (Node.js, using jose)

For a standalone diagnostic CLI, surfacing the confusion attempt explicitly is more useful than silent rejection:

1import { jwtVerify, createRemoteJWKSet } from 'jose';
2
3async function diagnose(token, trustedIssuer, allowedAlgs) {
4  const [headerB64] = token.split('.');
5  const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());
6
7  console.log(`[diagnostic] token declares alg=${header.alg}, kid=${header.kid}`);
8
9  if (!allowedAlgs.includes(header.alg)) {
10    console.error(`[FAIL] algorithm confusion suspected: ${header.alg} not permitted for ${trustedIssuer}`);
11    process.exit(1);
12  }
13
14  const JWKS = createRemoteJWKSet(new URL(`${trustedIssuer}/.well-known/jwks.json`));
15
16  try {
17    const { payload } = await jwtVerify(token, JWKS, {
18      issuer: trustedIssuer,
19      algorithms: allowedAlgs,
20    });
21    console.log('[PASS] signature and claims verified', payload);
22  } catch (err) {
23    console.error('[FAIL] verification error:', err.message);
24    process.exit(1);
25  }
26}

This mirrors the guidance in RFC 8725 (JWT Best Current Practices), which explicitly recommends that implementations reject tokens whose header algorithm does not match an application-defined allowlist, rather than deriving trust from the token content itself.

#Failure Modes and Edge Cases

A diagnostic tool that only handles the textbook RS256-to-HS256 case will miss several real-world variants that show up in bug bounty reports against production identity providers.

JWT algorithm confusion

#kid Header Injection

If key resolution builds a file path or SQL query from the kid value (e.g. ./keys/{kid}.pem), an attacker can supply kid: ../../../../dev/null to force a zero-length “key”, which some HMAC implementations will happily accept and verify against an empty secret. The diagnostic tool must validate kid against a strict allowlist pattern (e.g. UUID format) before any filesystem or database interaction, and log a specific warning when the pattern fails.

#Embedded JWK / x5c Confusion

Tokens carrying a jwk or x5c header field supply their own public key or certificate chain for verification. If the verifier trusts this field, the attacker simply generates a fresh keypair, signs the forged token, and embeds the matching public key. A hardened tool must ignore jwk/x5c headers entirely for any issuer configured with a static or JWKS-URI-based trust store, and only honour them in an explicitly-labelled “self-signed sandbox” mode.

#JWKS Cache Poisoning and Staleness

Caching the JWKS response is necessary for performance, but a long TTL creates a revocation gap — a compromised key remains trusted until the cache expires. Conversely, no caching turns every verification into a network round-trip against the identity provider, creating a denial-of-service vector if that endpoint degrades. The diagnostic tool should expose cache age in its output so operators can immediately correlate “verification passed” with “key was fetched 14 minutes ago,” which matters enormously during incident response.

#Timing Side Channels on Signature Comparison

HMAC comparisons using naive string equality (==) leak timing information proportional to the number of matching leading bytes. Any tool re-implementing signature comparison rather than delegating to a vetted library function (e.g. hmac.compare_digest in Python, or the constant-time compare in jose) reintroduces a side channel that a determined attacker can exploit over a sufficiently stable network path.

#Scaling and Security Trade-offs

Once the core validator is correct, the remaining decisions are about where the tool runs and how aggressively it caches trust material. Each choice carries a measurable trade-off:

  • Client-side (browser) diagnostic tools are convenient for quick debugging but risk exposing signing secrets if a developer pastes an HS256 token and its secret into a public tool — server-side or locally-run CLI variants avoid this exfiltration risk entirely.
  • JWKS cache TTL of 300–900 seconds balances revocation latency against identity provider load; dropping below 60 seconds materially increases load on the IdP during traffic spikes with negligible security benefit.
  • RS256/ES256 verification cost: RSA verification is roughly 3–5x more CPU-expensive than ECDSA (ES256) at equivalent security levels, which matters when the diagnostic tool is embedded in a high-throughput API gateway rather than run ad hoc.
  • Strict algorithm pinning versus algorithm agility: supporting multiple algorithms per issuer (for key rotation migrations) reintroduces a sliver of the original confusion surface unless each algorithm maps to a distinct, non-overlapping key type — never allow both RS256 and HS256 to resolve against related key material for the same issuer.
  • Fail-closed versus fail-open on JWKS fetch failure: fail-closed (reject all tokens) is correct for authentication paths; fail-open with cached-key fallback is acceptable only for non-critical diagnostic logging paths, clearly labelled as such in tool output.

Treat the diagnostic tool itself as a security-sensitive artefact, not merely a debugging convenience — its verification logic is the same code path that, if copied uncritically into production middleware, either closes or reopens the algorithm confusion class entirely.

Reader Interaction

Comments

Add a thoughtful note on Catching JWT Algorithm Confusion Attacks. Comments are checked for spam and held for moderation before appearing.

Loading comments...

Loading anti-spam check...

Eleanor Hayes

Written By

Eleanor Hayes

Dr Eleanor Hayes is a veteran cryptography researcher and enterprise security architect specialising in zero-trust network implementations. Having spent a decade securing critical national infrastructure, she now designs identity-aware proxy perimeters and mutual TLS topologies for highly distributed environments. She holds multiple GIAC certifications and regularly consults on mitigating advanced persistent threats within microservice ecosystems.

Related Architecture

View all in it-toolkit