← Back to blog

JWT Authentication Mistakes That Ship to Production

JWTs are everywhere because they are convenient. That same convenience hides a set of mistakes that are easy to make and hard to notice, because the app works fine right up until someone forges a token.

None of these mistakes is exotic. RFC 7519 defines the token format, and RFC 8725, the JSON Web Token Best Current Practices document published in February 2020, exists because the same failures kept turning up in real libraries and real apps. This post covers the ones most likely to be in your codebase right now, with the vulnerable line and the fixed line for Node's jsonwebtoken and Python's PyJWT.

What is the difference between decoding and verifying a JWT?

Decoding only base64url-decodes the token and hands back whatever claims are inside. Verifying recomputes the signature with your key and rejects the token when it does not match. A decoded token tells you what somebody wrote. Only a verified token tells you your server issued it, so authorization must only ever read verified claims.

The most dangerous JWT bug is calling decode() when you meant verify(). RFC 7519 describes the claims as a JSON object carried as the payload of a signed structure. Signed, not encrypted: anyone holding a token can read it, and anyone can write one. The signature is the only part that makes it yours. The jsonwebtoken README says of jwt.decode that it does not check the signature, and adds: "You should not use this for untrusted messages."

// Vulnerable: reads the claims, checks nothing
const payload = jwt.decode(token);
if (payload.role === 'admin') grantAccess();
// Fixed: throws unless the signature is valid and the token is unexpired
const payload = jwt.verify(token, process.env.JWT_SECRET, {
  algorithms: ['HS256'],
});

PyJWT has a naming trap. There, jwt.decode() is the verifying call, and the unsafe version is the one where someone switched the check off to get past an error:

# Vulnerable: signature check disabled
claims = jwt.decode(token, options={"verify_signature": False})

# Fixed
claims = jwt.decode(token, key, algorithms=["HS256"])

The PyJWT docs call the first form "generally ill-advised". Decoding in the browser to show a display name is fine. Decoding on the server to decide what someone may do is not. If your auth trusts decoded claims, your auth is theater.

If a user can change their own token's contents and your server believes it, you do not have authentication.

Can an attacker change the alg header of a JWT?

Yes. The header travels inside the token, so whoever sends the token controls it. RFC 8725 describes two attacks built on that: setting alg to none so no signature is checked, and switching RS256 to HS256 so the server verifies with its public RSA key as the HMAC secret. Pin the algorithms your verifier accepts.

The second attack works because a public key is public: the attacker signs with the key you published and the check passes. Tim McLean's write-up on the Auth0 blog named node-jsonwebtoken, pyjwt, namshi/jose, php-jwt and jsjwt as affected when used with asymmetric keys.

Libraries have tightened since. RFC 7518 says implementations must not accept unsecured tokens by default. PyJWT's changelog for 2.0.0 lists explicit algorithms in jwt.decode as required. The jsonwebtoken v9 migration notes say verify() no longer accepts unsigned tokens by default and that asymmetric keys can no longer be used for HMAC tokens.

Old versions are different. GitHub's advisory for CVE-2022-23540, published 21 December 2022, covers jsonwebtoken 8.5.1 and earlier: a jwt.verify() call with no algorithms set and a falsy secret could fall back to none. In practice, an unset JWT_SECRET on an old version meant unsigned tokens were accepted. Run npm ls jsonwebtoken and confirm you are on 9.0.0 or later.

The version of this bug you can still write yourself, on any release, is letting the token choose:

// Vulnerable: the token picks its own algorithm
const { header } = jwt.decode(token, { complete: true });
const payload = jwt.verify(token, key, { algorithms: [header.alg] });
// Fixed: one key, one hard-coded algorithm
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

PyJWT's docs say the same: never compute algorithms from the token's own alg, and do not mix HS and RS algorithms in one list. RFC 8725 requires each key to be used with exactly one algorithm.

How long should a JWT secret be?

For HS256, at least 256 bits of random data, which is 32 bytes. RFC 7518 requires an HMAC key the same size as the hash output or larger, and RFC 8725 says human-memorizable passwords must not be used directly as the key. Generate it from a cryptographic random source and never ship a fallback value.

A JWT is only as strong as its signing secret. A short secret, a guessable one, or the classic fallback below means an attacker can sign their own valid tokens.

// Vulnerable: anyone who has read a tutorial can sign tokens
const SECRET = process.env.JWT_SECRET || 'secret';

Length matters because guessing happens offline. Every user holds a token, and a token contains everything needed to test a candidate secret. No request reaches your server, so there is no rate limit and nothing in your logs. RFC 8725 names offline brute-force and dictionary attacks against weak HS256 keys specifically, and hashcat lists JWT as a standard hash mode (16500).

// Fixed: refuse to boot without a real key
const SECRET = process.env.JWT_SECRET;
if (!SECRET || Buffer.byteLength(SECRET) < 32) {
  throw new Error('JWT_SECRET is missing or shorter than 32 bytes');
}

Generate the value once with node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" and store it in your host's secret manager. PyJWT 2.11.0 added an InsecureKeyLengthWarning for keys below the RFC 7518 minimum. The jsonwebtoken README documents a 2048-bit minimum for RSA keys but no minimum for HMAC secrets, so that guard is yours to write.

A strong secret that sits in your repository is not a secret. If it was ever committed, treat it as burned and rotate it. See how indie apps leak their API keys and why deleting a secret from the current file does not remove it from git history.

Is it safe to store a JWT in localStorage?

Not for a token that authenticates a user. localStorage is readable by every script running on your origin, so one cross-site scripting bug or one compromised dependency can copy the token and replay it from anywhere. An HttpOnly cookie with the Secure and SameSite attributes keeps the token out of JavaScript's reach and is the safer default.

OWASP's HTML5 Security Cheat Sheet is blunt about this: do not keep session identifiers in local storage, because the data is always accessible to JavaScript and a single XSS flaw can steal all of it.

// Vulnerable
localStorage.setItem('token', token);
Set-Cookie: __Host-session=<token>; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=900

Each attribute does one job, as documented on MDN:

  • HttpOnly forbids JavaScript from reading the cookie through document.cookie.
  • Secure sends the cookie only over HTTPS.
  • SameSite=Lax withholds the cookie from cross-site requests unless they are top-level navigations using a safe method, which excludes POST, PUT and DELETE.
  • The __Host- prefix requires Secure, Path=/ and no Domain attribute, so the cookie is only ever sent to the host that set it.

HttpOnly does not fix XSS. MDN notes the cookie is still attached to requests JavaScript makes, so an injected script can act as the user while the tab is open. It just cannot carry the token away. Cookies also bring cross-site request forgery into scope, which SameSite narrows as long as no GET route changes state.

Expiry, audience and the claims nobody checks

Tokens with no expiry live forever. RFC 7519 makes the exp claim optional, and the jsonwebtoken README states there are no default values for expiresIn, audience or issuer. A bare jwt.sign(payload, secret) therefore produces a token that stays valid until you rotate the secret.

// Vulnerable: never expires, accepted by anything that shares the key
const token = jwt.sign({ sub: user.id }, SECRET);
// Fixed: set the claims when signing, check them when verifying
const token = jwt.sign({ sub: user.id }, SECRET, {
  algorithm: 'HS256',
  expiresIn: '15m',
  issuer: 'https://api.example.com',
  audience: 'example-web',
});

const payload = jwt.verify(token, SECRET, {
  algorithms: ['HS256'],
  issuer: 'https://api.example.com',
  audience: 'example-web',
});
  • Expiry. An expired token raises TokenExpiredError in jsonwebtoken and ExpiredSignatureError in PyJWT. Search your code for ignoreExpiration: true, which tends to survive from a debugging session.
  • Audience. RFC 7519 says a recipient that does not find itself in aud must reject the token, and RFC 8725 requires the claim whenever one issuer serves more than one relying party. The indie version is staging and production, or two apps, sharing one secret.
  • Required claims. A token with no exp has nothing to check. In PyJWT, pass options={"require": ["exp", "iss", "aud"]} so a missing claim is an error.

These checks map directly onto the current OWASP Top 10. Its Broken Access Control category lists tampering with a JWT among its failure modes, and its Authentication Failures guidance includes validating aud and iss.

How do you revoke a JWT before it expires?

You cannot revoke a signed JWT by itself, because verifying it never touches your database. Revocation needs server-side state: a deny list keyed on the token's jti claim, or a short-lived access token paired with a refresh token you can delete. OWASP's JWT cheat sheet recommends a deny list of revoked sessions or tokens.

A leaked token you cannot revoke is a permanent backdoor. For a small app the simplest workable design is a 15 minute access token plus a refresh token stored as a random value in a database row. Logout deletes the row. A password change deletes every row for that user. The worst case for a stolen access token is then its remaining lifetime.

RFC 9700, the OAuth 2.0 security best practice published in January 2025, describes refresh token rotation: every refresh issues a new refresh token and invalidates the old one, so a replayed old token signals theft. If you need immediate cut-off, add the deny list:

const payload = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
if (await revoked.has(payload.jti)) {
  throw new Error('token revoked');
}

Set the ID with the jwtid option when signing. The OWASP cheat sheet advises keying the list on the jti and iss claims. Entries only need to live until the token's own expiry.

The JWT check to run before you ship

In order:

  1. Search server code for jwt.decode( and verify_signature. Any hit that feeds an authorization decision becomes a verify call.
  2. Confirm every verify call passes a hard-coded algorithms list from one family.
  3. Check versions: jsonwebtoken 9.0.0 or later, PyJWT 2.0.0 or later.
  4. Search for a fallback such as || 'secret' and make the app refuse to start without a 32-byte key.
  5. Confirm the secret was never committed, including in old commits.
  6. Check every sign call sets expiry, issuer and audience, and every verify call checks them.
  7. Move the token out of localStorage and into an HttpOnly, Secure, SameSite cookie.
  8. Log out, then try to refresh with the old refresh token. It should be rejected.
  9. Forge one: edit the payload of a real token, keep the old signature, and send it. Anything other than a 401 is a finding.

If Supabase issues your tokens, the same claims feed your Row Level Security policies, and that needs its own check: see whether the Supabase anon key is safe to expose. For everything outside auth, work through the pre-launch security checklist for indie developers or browse the rest of the app security guides.

Scanning for this before you ship

Auth is the one area where "it works" and "it is safe" are very different claims, and every item above can be checked by hand in an evening. If you would rather have something read your source first, the free IOnclad browser scanner runs a 167-rule subset entirely in your browser, with no signup. Your code stays in the tab.

The IOnclad desktop app runs 24 scanners and 500+ checks and returns one Ship-It verdict with the file, line and a fix for each finding. It does not promise your app is secure, and no scanner honestly can.

Try it free
IOnclad

The pre-launch security scanner that answers "Am I safe to ship?" in under a minute. Runs 100% offline, free to start.

→
Keep reading
IOnclad Firebase Security Rules Are Open by Default Read → IOnclad Hardcoded Secrets: How Indie Apps Leak Their API Keys Read → IOnclad The OWASP Top 10, Explained for Solo Developers Read →
Reading us on Google?
Add The IOn Project as a preferred source

One click on Google’s preferences page, and our articles show up more often in your Top Stories, AI Overviews, and AI Mode.

→