JSON Web Tokens (JWTs) are widely used for API authentication and single sign-on. The format is simple, but it leaves many security decisions to the developer and the library. RFC 8725, the JWT Best Current Practices, collects the known mistakes and how to avoid them; this post walks through the ones we would check first.
A JWT is signed, not secret
A signed JWT, defined in RFC 7519, has three parts separated by dots: a header, a payload of claims and a signature. The first two are only base64url-encoded. Anyone holding the token can read them. Here is a decoded example:
{
"alg": "RS256",
"typ": "at+jwt",
"kid": "2026-10-key-1"
}
{
"iss": "https://auth.example.eu",
"sub": "user-4821",
"aud": "api.example.eu",
"iat": 1792140000,
"exp": 1792140900,
"role": "editor"
}
The signature proves the claims have not been changed. It does not hide them. RFC 7519 says that when a token contains privacy-sensitive information, measures must be taken to prevent its disclosure, for example by using an encrypted JWT (JWE). In practice, keep personal data, internal identifiers you would not publish and anything secret out of the payload.
Pitfall 1 - accepting "alg": "none"
RFC 7519 allows "unsecured" JWTs with "alg": "none" and an empty signature. RFC 8725 describes libraries that accepted these tokens and skipped signature checks entirely. An attacker could then write any claims they liked.
RFC 7518 states that implementations must not accept unsecured tokens by default, and the OWASP REST Security Cheat Sheet says not to allow them at all. Check that your library rejects none, and test it with a crafted token.
Pitfall 2 - letting the token choose the algorithm
The header's alg value is chosen by whoever made the token, which may be an attacker. RFC 8725 describes the classic confusion attack: a server expects RS256, verified with an RSA public key. The attacker changes alg to HS256 and signs the token with HMAC, using that public key as the secret. A library that follows the header verifies it successfully, and the public key is, by definition, public.
The fix is to decide the algorithm on the server. OWASP says verification must not rely on the JWT header to select the algorithm. RFC 8725 adds that each key should be used with exactly one algorithm. In code, that looks something like:
verify(token,
key = issuer_public_key,
algorithms = ["RS256"],
issuer = "https://auth.example.eu",
audience = "api.example.eu")
Pitfall 3 - weak HMAC secrets
With HS256, anyone who captures a token can try to guess the secret offline, as fast as their hardware allows. RFC 8725 states that human-memorisable passwords must not be used directly as HMAC keys. RFC 7518 requires a key at least as long as the hash output, so 256 bits for HS256.
openssl rand -base64 32
That gives 32 random bytes, which is 256 bits. Store it in a secrets manager, not in the repository.
Shared secrets have a second weakness. Every service that can verify an HMAC token can also create one. OWASP prefers signatures over MACs for this reason: if several services validate tokens, one compromised service could otherwise mint tokens for all of them. Asymmetric algorithms such as RS256 or ES256 let you share only the public key.
Pitfall 4 - skipping claim validation
A valid signature only proves who issued the token. The claims decide whether this service should accept it now.
exp- RFC 7519 says the token must not be accepted on or after this time. A small leeway for clock skew, usually no more than a few minutes, is allowed.nbf- if present, reject the token before this time.iss- check that the issuer is the one you trust and that it owns the signing key.aud- RFC 7519 says that if the service does not find itself in the audience, the token must be rejected.
OWASP recommends requiring and validating iss, aud and exp by default for API access tokens. RFC 8725 explains why the audience matters. Without it, a token issued for one service can be replayed at another that trusts the same issuer. It also recommends explicit typing with the typ header, so that one kind of token cannot be used where another is expected. For OAuth access tokens, RFC 9068 uses at+jwt, as in the example above.
Pitfall 5 - trusting kid, jku and x5u
Header parameters that tell the server where to find the key are also attacker-controlled. RFC 8725 lists the risks:
kid(key ID) used in a database or directory lookup can enable SQL or LDAP injection. The same applies if it is used to build a file path.jkuandx5upoint to a URL for fetching keys. Following any URL allows server-side request forgery, and an attacker could point it at their own key.
Treat kid as an opaque label and look it up in a fixed list of keys you control. Only fetch keys from an allowlist of URLs, and RFC 8725 adds that cookies should not be sent with those requests.
Pitfall 6 - long-lived tokens and no revocation
A JWT stays valid until it expires, even after the user logs out or an administrator disables the account. OWASP notes that tokens can outlive the session they represent.
- Keep access tokens short-lived, measured in minutes rather than days.
- Use refresh tokens that the server checks on every use and can revoke.
- For logouts and account suspensions, keep a denylist of token IDs (
jti) until those tokens expire, as OWASP suggests. - Rotate signing keys on a schedule, and have a tested process for an emergency rotation.
Pitfall 7 - storing tokens where scripts can read them
Tokens kept in localStorage can be read by any JavaScript on the page. The OWASP HTML5 Security Cheat Sheet warns that a single cross-site scripting flaw can steal everything in these storage objects, and advises against keeping session identifiers there.
For browser applications, an HttpOnly, Secure cookie with a suitable SameSite setting keeps the token away from scripts. Cookies bring cross-site request forgery into scope, so plan for that too. Whatever you choose, a strong Content Security Policy reduces the chance of XSS in the first place.
A validation checklist
- Pin the accepted algorithms on the server, and reject
none. - Use a library that verifies the signature before you read any claims.
- Validate
exp,nbf,issandaudon every request. - Use
typor distinct audiences to separate token types. - Look up keys by
kidin a fixed set; never fetch keys from arbitrary URLs. - Use random HMAC keys of at least 256 bits, or asymmetric keys.
- Keep payloads free of secrets and unnecessary personal data.
- Keep access tokens short-lived and support revocation.
- Store browser tokens out of reach of JavaScript.
Where to start
Paste a token from your test environment into our JWT decoder to see exactly what it reveals, and check its alg, aud and exp values against this list. Avoid pasting live production tokens into any online tool.
If you want a second opinion on your authentication design or a code review of your token handling, our secure software development team can help.
