Skip to content

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, Python cryptography, 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 k values, 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.
  • iss and aud are 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.