Type
Analysis
Published
Reading time
6 min read
Author
rootzero
Lire en français
Lire en français →

JWT: five implementation flaws and their fixes

alg none, algorithm confusion, weak HMAC secrets, kid injection, tokens that never expire: five classic JWT mistakes and how to fix them.

  • Web
  • API
  • Authentication

A signed JWT (JWS) has a header, a payload and a signature, each Base64url-encoded and separated by dots. An encrypted JWT (JWE) has five segments; this article concerns signed tokens. The format protects nothing by itself. All of the security depends on how the server verifies the token, and that is where the mistakes concentrate.

The five mistakes described here come up regularly in API penetration tests. None of them is new, and most appear in the best current practices published by the IETF in 2020 (RFC 8725). They persist because each one starts from an assumption that seems reasonable when you write the code.

A reminder of the structure

A signed JWT (RFC 7519, which builds on JWS, RFC 7515) breaks down like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxMDQyIiwi... . 3q2-7w...
└───────────── header ─────────────┘   └────── payload ──────┘   └ signature ┘

The decoded header states the algorithm ({"alg":"HS256","typ":"JWT"}). The payload carries the claims: sub, exp, iss, aud, roles and so on. The point that explains almost every mistake below: the header is supplied by the client. The alg, kid and jku values are untrusted data until the signature has been verified, and they must never decide how it is verified.

Our JWT Inspector decodes a token in the browser, without sending it to a server, and flags several of the problems described here.

1. Accepting alg: none

The JWA specification (RFC 7518) defines a none algorithm for unsecured tokens, meaning tokens without a signature. If the library reads the algorithm from the header and applies it as is, anyone can forge a token that will be accepted:

# Header {"alg":"none","typ":"JWT"}, payload {"sub":"1042","role":"admin",...}
# The third part is empty: no signature.
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMDQyIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzg4MDAwMDAwfQ.

Widely used libraries now reject this case by default. The flaw resurfaces in home-grown code, in old wrappers and in filters that forget case variants (None, NONE). It was widely documented in 2015 in the article Critical vulnerabilities in JSON Web Token libraries.

The fix is to set the list of accepted algorithms on the server side, and never to derive it from the token.

2. RS256 to HS256 algorithm confusion

This is a subtler variant of the same flaw. An API signs its tokens with RS256: the private key signs, the public key verifies. That public key is often exposed at a JWKS endpoint, which is normal.

Suppose the verification code looks like verify(token, key) and lets the header choose the algorithm. An attacker can then:

  1. retrieve the RSA public key;
  2. forge a token whose header announces "alg": "HS256";
  3. sign it with HMAC, using the public key as the secret.

The library reads HS256, treats the supplied key (the public key in PEM format, so plain text) as an HMAC secret, and the signature verifies. The attacker has signed a valid token without ever holding the private key.

The fix combines an explicit algorithm list with typed key objects, so that an RSA key cannot be used as an HMAC secret. With PyJWT:

import jwt  # PyJWT >= 2

claims = jwt.decode(
    token,
    key=public_key,                 # RSA key object loaded at startup
    algorithms=["RS256"],           # set by the server
    audience="api.example.com",
    issuer="https://auth.example.com/",
    options={"require": ["exp", "iat", "sub"]},
    leeway=30,                      # clock skew tolerance, in seconds
)

In Node.js, the jose library follows the same logic:

import { jwtVerify, createRemoteJWKSet } from 'jose';

const jwks = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
const { payload } = await jwtVerify(token, jwks, {
  algorithms: ['RS256'],
  issuer: 'https://auth.example.com/',
  audience: 'api.example.com',
});

3. A guessable HMAC secret

With HS256, the token is exactly as strong as the shared secret. A signed JWT contains everything needed for an offline attack: the message (header and payload) and its signature. With a single captured token, an attacker can try candidate secrets at high speed on their own machine, without ever touching your server and therefore without triggering an alert. Hashcat supports this format natively (mode 16500, see its example hashes).

The first candidates tried are predictable: secret, changeme, the application name, the sample value from the framework documentation.

RFC 7518 (section 3.2) requires a key at least as long as the hash output, so 256 bits for HS256. Generate it from a cryptographic random source:

# 32 random bytes = 256 bits
openssl rand -base64 32
# or in Python
python3 -c "import secrets; print(secrets.token_urlsafe(32))"

Keep it in a secrets manager, outside the Git repository, and plan for rotation (one of the legitimate uses of kid). If several services verify tokens, asymmetric signing is preferable: only the issuers then hold the private key.

4. Trusting kid and jku

The kid (key ID) parameter names the key used for signing, which makes rotation easier. It becomes dangerous as soon as the server uses it as is to fetch the key.

  • Used as a file path, kid: "../../../../dev/null" loads an empty file, and a token signed with an empty key may then be accepted.
  • Concatenated into an SQL query such as SELECT key FROM keys WHERE id = '…', it opens a classic SQL injection, which can make the query return a key chosen by the attacker.
  • Less often, it ends up in a system command, in home-grown integrations.

The jku and x5u parameters raise a similar problem, because they name a URL where the key can be found. If the server follows that URL, the attacker hosts their own key there and signs whatever they want. The same mechanism also lets an attacker make the server send requests (SSRF).

The fix treats kid as an opaque identifier, looked up in a fixed table of known keys; an unknown value leads to rejection. jku and x5u are ignored, or checked against a strict allow list of URLs.

TRUSTED_KEYS = {"2026-09": key_sept, "2026-10": key_oct}  # loaded at startup

def key_for(token: str):
    kid = jwt.get_unverified_header(token).get("kid")
    if kid not in TRUSTED_KEYS:
        raise jwt.InvalidTokenError("unknown kid")
    return TRUSTED_KEYS[kid]

5. Tokens with no expiry and no revocation

A JWT is self-contained: the server validates it without looking up any state. That is both its appeal and its limit. A token with no exp, or one valid for several months, survives logout, a password change and the employee leaving. If it leaks (an application log, a header pasted into a ticket, a malicious browser extension), it stays usable for that whole period.

Several measures work together in practice. The access token is short-lived, from a few minutes to an hour depending on sensitivity. A refresh token, kept on the server side and single-use, is replaced on every use; reuse indicates theft and must invalidate the whole session. A revocation list on jti covers emergencies, and its size stays bounded since tokens expire quickly. Finally, the server checks iss, aud and nbf as well as the signature, because a token correctly signed for another service has no business being accepted by yours.

Checklist before going to production

Check Expected
Algorithms List set on the server side, none rejected
Keys Typed objects; a public key is never used as an HMAC secret
HMAC secret At least 256 random bits, outside the code, rotation planned
kid Looked up in a fixed table, unknown value rejected
jku, x5u Ignored, or strict allow list
Claims exp required; iss, aud and nbf checked
Lifetime Short, refresh with rotation
Revocation Possible and tested

OWASP maintains a JWT cheat sheet that covers each of these points in detail. To examine a real token without handing it to a third-party service, the JWT Inspector runs entirely in your browser.

#Free tool

Inspect your tokens, locally.

The JWT Inspector decodes header and claims, verifies the signature and flags alg none or a missing expiry. Nothing leaves your browser.