JWT pitfalls and best practices
Five common JWT footguns — alg=none, weak HMAC keys, missing exp, trusting kid blindly, mixing JWS and JWE — and how to avoid each one in 2026.
JWT — JSON Web Token — is the default token format for HTTP APIs in 2026. The spec is small (RFC 7519), the libraries are everywhere, and a token is just three base64url segments joined by dots. That apparent simplicity is the problem: every footgun in this guide has shipped to production at a company you have heard of, often more than once. The defenses are straightforward once you know what to look for.
This article walks through five recurring failure modes — alg=none,
underweight HMAC secrets, missing exp, trusting kid as a path, and
confusing JWS with JWE — and ends with an opinionated take on which
algorithms to pick for new systems.
alg=none — the original sin
In 2015 Tim McLean published the alg=none attack against early JWT
libraries. The JWT header declares which algorithm signed the token, and
a class of libraries trusted that declaration unconditionally. An attacker
sent a token whose header said {"alg":"none"} and whose signature
segment was empty; the library obediently skipped verification and
treated the token as authentic. Auth0, node-jsonwebtoken, pyjwt and a
long tail of forks all shipped variants of the same bug.
RFC 8725 (JWT Best Current Practices, 2020) names this as the first mitigation: the verifier — not the token — decides which algorithms are acceptable. Concretely, every call site should pass an explicit allowlist:
// vulnerable: trusts the alg header
const payload = jwt.verify(token, key);
// fixed: pin the algorithm
const payload = jwt.verify(token, key, { algorithms: ["RS256"] });
The same rule applies to confusion attacks where a token signed with
HS256 is presented to a verifier expecting RS256. If the verifier
falls back to "use the alg header to pick the routine," it will hash the
public key as an HMAC secret — and any party with the public key (which
is, by definition, public) can forge tokens. Pin the algorithm.
Weak HMAC secrets
HS256 is symmetric: the same secret signs and verifies. RFC 7518 §3.2
is unambiguous — the key MUST be at least as long as the HMAC output, so
32 bytes for HS256, 48 for HS384, 64 for HS512. A six-character
password is not a key, it is a five-minute offline brute force on a
laptop. There are public tools that crack short-secret JWTs in seconds
against a wordlist.
Generate secrets at full entropy and store them in a secret manager:
# 32 random bytes, base64url-encoded — paste into your secret store
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
// Node — produce a fresh HS256 secret programmatically
import { randomBytes } from "node:crypto";
const secret = randomBytes(32); // raw bytes; encode for storage
Never check the secret into source control, never derive it from an environment variable name, and rotate it on a schedule — RFC 8725 §2.1 explicitly recommends rotation.
Missing exp
A JWT without an expiry is a forever-token. Once it leaks — a forgotten log line, a browser extension, a misconfigured proxy — the only way to invalidate it is to rotate the signing key, which invalidates every token, which usually means a global logout. That is a bad incident response plan.
Pick a window that matches the blast radius:
- Access tokens: 5–15 minutes. Short enough that a leak self-heals.
- Refresh tokens: hours to days, stored only on trusted clients, with rotation on use.
- ID tokens (OIDC): minutes; they prove identity at login and should not be reused.
Always verify exp on the server. Some libraries do this by default,
some do not — and the ones that do still let you disable it for tests.
Audit your verify call sites:
const claims = jwt.verify(token, key, {
algorithms: ["RS256"],
// do NOT pass { ignoreExpiration: true } in production
});
If you need long-lived authorization (a CLI tool, a CI runner), issue
a short-lived JWT and a separately tracked refresh token — do not stretch
exp to a year.
Trusting kid blindly
kid (key id) is a header used for key rotation: the issuer tags each
token with the id of the signing key, the verifier looks up the matching
public key, and rotation works without downtime. The trap is that kid
is part of the header, which means it is attacker-controlled. Treat
it as untrusted input.
The classic failures:
// path traversal — kid becomes a filesystem path
const key = fs.readFileSync(`/etc/keys/${header.kid}.pem`);
// attacker sends kid = "../../../../dev/null" or a public path
// SQL injection — kid becomes a query parameter
const key = await db.query(`SELECT pem FROM keys WHERE id = '${header.kid}'`);
// URL fetch — kid becomes a remote endpoint
const key = await fetch(header.kid).then(r => r.text());
// SSRF against internal services, or attacker-hosted "public key"
The fix is the same as for any other untrusted identifier: look it up in
a whitelist. Maintain a key set indexed by kid, reject any token whose
kid is not in the set, and never let the kid value reach a filesystem
call, a database driver, or an HTTP client. For OIDC, the JWKS URI is
fixed configuration; you fetch the full set and pick by kid from the
result, not the other way around.
JWS vs JWE — pick the right one
JWT is a payload shape; the wrapper is either JWS (signed) or JWE (encrypted). The two solve different problems and people regularly reach for the wrong one.
- JWS proves who issued the token. Anyone can read the payload — just base64url-decode the middle segment. It is integrity, not confidentiality.
- JWE proves what the payload is — it is encrypted. The recipient needs the decryption key; anyone else sees only ciphertext.
If your token carries a user id and an expiry, JWS is correct: the recipient verifies the signature and reads the claims. If your token carries a session secret, a feature flag the user must not see, or PII you do not want logged, JWS is not enough. Use JWE, or — more often — do not put the sensitive value in the token at all and store it server side keyed by an opaque session id.
When you genuinely need both signing and encryption (a signed token delivered confidentially to a known recipient), the spec answer is nested JWT: sign first, then encrypt the JWS as the JWE payload. Encrypting an unsigned payload gives you confidentiality without authentication, which is rarely what you want.
Verifying tokens
For one-off debugging — a token from a log, a header from a curl
response, a sample from a vendor — paste it into the
JWT Decoder & Verifier. The tool decodes the header and payload
without verifying (safe for any token), then optionally verifies the
signature against a key you supply. It implements the full alg matrix
(HS/RS/PS/ES/EdDSA) and supports JWE decryption with A128/192/256GCM and
the common key-wrapping algorithms. The warnings engine flags alg=none,
weak HMAC keys, expired tokens, and the other footguns in this article
as you paste.
Use it for diagnosis, not as a verification step in production code — production verification belongs in a library inside your service, with the algorithm pinned and the key loaded from your secret manager.
Algorithm picks for 2026
If you are starting a new system today, here is the short opinion:
- EdDSA (Ed25519) is the best default for asymmetric tokens. Small
keys (32 bytes), small signatures (64 bytes), deterministic, no
parameter footguns, fast verification. Supported by
jose,golang.org/x/crypto/ed25519, Pythoncryptography, and most modern JWT libraries. - RS256 remains the most interoperable choice for OIDC providers and legacy SaaS integrations. Keys are larger (2048 bits minimum, 3072 preferred for new keys) but every JWT library supports it. Use RS256 when you need to publish a JWKS that arbitrary clients will consume.
- HS256 is fine for service-to-service tokens where both sides are yours and you can manage the shared secret in a vault. It is the wrong choice for tokens that cross a trust boundary — anyone with the verification key can forge tokens.
- ES256 is a reasonable middle ground when EdDSA is not available;
it is slower than EdDSA and has historically had implementation
pitfalls around random
kvalues, but the modern libraries handle it correctly.
Avoid RS1, HS1, and anything else that pins to SHA-1 — SHA-1 is
broken for collision resistance and there is no reason to ship a new
SHA-1-based token in 2026. Avoid the none algorithm everywhere,
including in test fixtures, so that nobody accidentally promotes a
test stub to production.
Closing checklist
Before shipping a JWT verifier to production:
- Pin the algorithm in the verify call. No fallback to the header.
- HMAC secrets are at least 32 bytes, generated with a CSPRNG.
- Asymmetric keys come from a JWKS or a pinned PEM, not from
kid. - Every token has
exp, and the verifier enforces it. -
issandaudare checked against an expected value. - No sensitive data in the payload if the token is JWS (signed-only).
- Test fixtures use real algorithms, never
none.
The defense is shallow — half a dozen invariants, all enforceable in a shared helper. The offense, when those invariants slip, is total account takeover. Worth the audit.