DEV Community

Davi
Davi

Posted on Originally published at blog.mago.team

JWT Vulnerabilities Are API Design Bugs: Three Implementation Decisions That Betray Cryptographic Intent

JWT Vulnerabilities Are API Design Bugs

A token arrives signed RS256. The server verifies it. Access granted. The attacker signed it with HS256 using the server's own public key. The cryptography worked perfectly; the design decision was the attack.

CVE-2024-54150, CVE-2026-29000, CVE-2026-22817: three advisories published within two years, same root cause. None of them broke RSA or HMAC. All of them exploited the gap between what the cryptographic specification guarantees and what production code does.

alg:none and Algorithm Confusion: The Header Is Attacker Input, Not a Config File

The alg field in the JWT header is attacker-controlled. When the library uses that field to select the verification function, the attacker picks their own defense.

CVE-2024-54150 affects the C library cjwt (CVSS 9.3 NVD, 10.0 GitHub). The cjwt_decode() function reads alg from the header and selects the verifier without restriction. The attacker replaces RS256 with HS256 and signs with the server's public key: a key that is public by definition. The server verifies with the same key, believing it is the HMAC secret. Verification passes.

CVE-2026-29000 (pac4j-jwt, CVSS 10.0) shows the JWE variant. The inner JWS accepts alg:none via PlainJWT, letting attackers forge admin tokens using only the public /jwks endpoint. CVE-2026-22817 (Hono middleware, patched in 4.11.4) replicates the same pattern in Node.js. Blocklists checking the literal string "none" fail against "NONE", "None", or "nOnE".

The root is architectural: the JWT header is not a trusted config file. It is attacker input crossing an authentication boundary. Algorithm pinning happens on the server, not in the token.

Weak HMAC Secrets: RFC 7518 Requires 256 Bits, Production APIs Ship 'secret'

RFC 7518 section 3.2 is explicit: HS256 requires a key of at least 256 bits, generated via a cryptographic PRNG. Most tutorials appearing in search results omit this requirement entirely.

hashcat mode 16500 (JWT HS256) runs at approximately 500 million hashes per second on a consumer GPU. The rockyou.txt dictionary has 14 million entries. A dictionary-based secret falls in under 30 milliseconds. The wallarm/jwt-secrets repository lists real secrets extracted from public repositories: secret, password, changeme, application names, framework-generated strings.

The attack is fully offline. The attacker captures one valid JWT from any public endpoint and runs hashcat locally, never touching the authentication server. Logs record nothing. Secret entropy is the only mitigation.

OWASP sets a minimum of 256 bits of entropy for JWT HMAC secrets. RFC 7518 requires 256 bits. Use openssl rand -base64 32 or a cryptographic equivalent, never a password-derived value.

JWK Injection and JKU Redirect: Supplying Your Own Trust Anchor

JWK injection and JKU redirect share the same premise: the server accepts verification material from the token itself.

In JWK injection, the attacker generates an RSA key pair and embeds the public key in the header's jwk parameter. The token is then signed with the corresponding private key. The server reads jwk, verifies the signature with the embedded key, and confirms the signature is valid. It is. RFC 7515 defines the jwk parameter but does not mandate rejection of external keys. The vulnerability is the absence of a key allowlist.

CVE-2026-48522 affects PyJWT's PyJWKClient. The library does not validate the URL scheme in the jku parameter, accepting file://, ftp://, and data:. An attacker controlling the jku value can redirect the server to an SSRF endpoint or supply a JWK Set via data URI. The CVE-2024-21643 pattern (local file read via file://) is the most common variant in production environments.

The mitigation is architectural, not in the library. JWKS URIs belong in a static server-side allowlist. The jwk and jku parameters in untrusted tokens must be ignored before verification.

kid Header Injection: When Key Lookup Becomes Query Injection

The kid (Key ID) parameter tells the server which key to use for verification. Frameworks that build file paths or database queries directly from kid expose CWE-22 and CWE-89 in a JWT context.

The path traversal pattern uses kid: "../../../dev/null". The server opens the file, reads empty content, and uses the empty string as the HMAC secret. The attacker sends an HS256 token signed with an empty string. Verification passes. The SQL injection pattern uses kid: "x' UNION SELECT 'attacker-key'--". The server executes the query, gets the attacker-controlled value as the secret, and verifies the signature against it. Verification passes.

No CVE exists for kid injection as a class. It is not a JWT library vulnerability. It is classic injection occurring in a JWT context. The fix is the same in both cases: allowlist alphanumeric characters and hyphens for kid. Use parameterized queries for database lookups and never construct file paths from user input.

Token Context Binding Failures: Valid Signature, Wrong Context

A valid JWT signature does not prove the token is appropriate for the context it is being used in. CVE-2025-30144 (fast-jwt below 5.0.6, CVSS 6.5) demonstrates this in the iss claim.

The attacker builds a token with iss: ["https://attacker.example/", "https://api.legitimate/"]. The library accepts the array and checks whether any element matches the expected issuer. The legitimate domain is in the array; verification passes. The token was issued by the attacker.

Similar failures appear across the full claims chain. Tokens without aud validation are accepted by any service, not only the intended target: a token for service A is accepted by service B. Long TTLs (30 or 90 days) keep revoked users with active access for weeks. Refresh tokens without rotation enable replay after compromise. A sub claim without tenant scoping allows cross-tenant elevation even when iss and aud are correct.

All of these failures are independent of cryptography. The JWT is perfectly signed. The problem is that the signature does not cover the token's fitness for context.

Per-Library Hardening: Specific API Calls That Close Each Attack Class

"Validate the algorithm" is unusable advice. Each library exposes a different API; hardening must be library-specific to be actionable.

jsonwebtoken (Node.js): The algorithms option is mandatory. Omitting it produces no error in older versions; it produces insecure behavior silently.

// Wrong
jwt.verify(token, secret);

// Correct
jwt.verify(token, secret, { algorithms: ['HS256'] });
Enter fullscreen mode Exit fullscreen mode

PyJWT (Python): algorithms is a required parameter since PyJWT 2.0. A call without algorithms= emits DecodeWarning but does not reject the token in all versions.

# Wrong
jwt.decode(token, key)

# Correct
jwt.decode(token, key, algorithms=['RS256'], audience='api')
Enter fullscreen mode Exit fullscreen mode

python-jose: verify=False silently skips verification. Always pass algorithms explicitly.

from jose import jwt
payload = jwt.decode(token, key, algorithms=['RS256'])
Enter fullscreen mode Exit fullscreen mode

Auth0 java-jwt: The algorithm is passed to Algorithm.RSA256(), not read from the header. Never use Algorithm.none().

JWT.require(Algorithm.RSA256(publicKey, null))
   .withAudience("api")
   .build()
   .verify(token);
Enter fullscreen mode Exit fullscreen mode

System.IdentityModel (.NET): ValidAlgorithms is an explicit allowlist in TokenValidationParameters.

new TokenValidationParameters {
    ValidAlgorithms = new[] { "RS256" },
    ValidateIssuer = true,
    ValidIssuer = "https://api.example/",
    ValidAudience = "api"
}
Enter fullscreen mode Exit fullscreen mode

The MAGO team tool (mago.team) inspects JWT tokens returned by authentication endpoints. It checks the alg field, tests algorithm bypass, and measures secret entropy when the algorithm is HS256.

The fix is not cryptographic: it is architectural. Pin the algorithm server-side, generate secrets with a CSPRNG at RFC 7518's 256-bit minimum, validate all claims (iss, aud, sub, exp), and allowlist JWKS URIs. Each of these is one config line. The gap between vulnerable and hardened is not mathematical sophistication: it is reading the spec.

Top comments (0)