Skip to main content
Security & Privacy

JWT Authentication Mistakes That Keep Appearing in Production

Algorithm confusion, weak secrets, missing expiry, and the library defaults that quietly changed underneath all the advice written about them.

By 9 min read
Article title card. Three segments in a row, the last one amber and labelled sig, with the line: the third part is the only one that matters

JWT mistakes have been documented for a decade and keep shipping anyway. Not because the failure modes are hard to describe, but because the correct answer depends on which library you have, which version of it, and what that version does when you leave an option out. Advice written in 2018 about a library that has since changed its defaults is worse than no advice, because it is specific enough to be believed.

So here is the current state of it, with the parts that changed marked as changed.

The shape of the thing

A JWT is three Base64URL segments separated by dots. Header, payload, signature. The header names the algorithm, the payload carries claims, and the signature covers the first two joined by a dot.

A JWT split into three segments. The header and payload are marked attacker-controlled, and only the signature is marked as requiring a key
Reading the algorithm out of the header means letting the sender pick how you check the sender.

The payload is encoded, not encrypted. Anyone holding the token can read it, which our JWT Decoder will demonstrate on any token you paste, without a key and without sending it anywhere.

Both of the classic attacks come from the same root: the header is untrusted input, and some verifiers used it to decide how to verify.

The none algorithm, and who was actually at fault

The attack is old and simple. Take a real token, rewrite the payload to {"sub": "admin"}, set the header to {"alg": "none"}, leave the signature empty, send it. A verifier that trusts the header sees an algorithm meaning "no checking required" and complies3.

The usual telling blames the specification for allowing none at all. That is not what the specification says. RFC 7518 requires that an implementation must not accept an unsecured JWS as valid unless the application has explicitly asked for it, on that specific object, and explicitly says implementations must not accept them by default2. The libraries that did were not following a permissive spec. They were ignoring a restrictive one.

The well-known libraries fixed this a decade ago. Pass the algorithm list anyway:

jwt.verify(token, secret, { algorithms: ['HS256'] });

Not because your library is probably broken, but because that line is an assertion you can read in a review, and the alternative is knowing the defaults of every library in the dependency tree.

Algorithm confusion

Diagram of the RS256 to HS256 attack. The server signs with an RSA private key and publishes the public key; the attacker signs a forged token with HMAC using that public key as the secret, and the server accepts it
Nothing here is a broken cipher. It is one type confusion between a key and a secret.

Your server signs with RS256 and publishes the RSA public key, as it should. An attacker takes that public key, writes a token with "alg": "HS256", and HMAC-signs it using the public key bytes as the shared secret. A verifier that reads alg from the header loads "the key", hands it to its HMAC path, and the signature checks out3.

The published key was never supposed to be a secret. The bug is that the token got to decide which role it played.

What changed: jsonwebtoken now derives an allowlist from the key you pass when algorithms is omitted, so a secret gets the HS family and an RSA key gets the RS family4. That closes both attacks by default in that library. It does not close them in code that reads alg itself to pick a key, which is a pattern people still write by hand when juggling multiple issuers.

Secrets that are not secrets

HS256 is HMAC-SHA256 and is exactly as strong as the key. RFC 7518 sets a floor: a key at least the size of the hash output, so 256 bits, and it says MUST2.

The failure is never a slightly-too-short random key. It is a word. A JWT_SECRET set to changeme while someone was getting a staging environment up, then carried into production by the same deploy script.

Look at what that is worth. As a random string, eight lowercase letters is about 37.6 bits, already weak. But changeme is not a random string, it is a wordlist entry, so the real number is closer to the position of that word in rockyou.txt. hashcat has a dedicated JWT mode, 165005, and a captured token is all the input it needs. This is an offline attack. Nothing rate-limits it and nothing logs it.

Generate the key from a cryptographic source and never type it yourself:

openssl rand -base64 32

Our Random String Generator does the same job in the browser using crypto.getRandomValues with rejection sampling, so the character distribution is not skewed by a modulo. If you want to see what a candidate secret is actually worth before trusting it, the Password Entropy Calculator puts a number on it.

Then treat it like a database password: secret manager, rotation schedule, never in git.

Expiry, and the revocation problem hiding behind it

exp is the time on or after which the token must not be accepted1. A token minted without one is valid until you rotate the signing key, which invalidates every token you ever issued, for everyone, at once. That is not a revocation mechanism, it is an outage.

Reasonable windows: fifteen minutes for API access tokens, hours to weeks for refresh tokens that live somewhere you can revoke them, an hour at most for one-shot links like password resets.

Which is the whole revocation story, really. A verifier that checks a signature and a timestamp has no way to hear that you changed your mind. Any denylist or allowlist reintroduces the database lookup that statelessness was meant to remove. Short access tokens plus a database-backed refresh token is not an elegant answer, it is the working one: the thing you cannot revoke is only briefly valid, and the thing that lasts is one you can delete.

If your library offers an option to skip expiry checks, it exists for tests. It has no production use.

Clock skew, without the folklore

Your issuer and your verifier are different machines with different clocks, so a token minted a moment ago can look like it starts in the future. Libraries take a clockTolerance in seconds, applied to nbf and exp4.

Do not disable it, do not set it to an hour, and do not assume a helpful default is there. RFC 7519 puts the sane range in writing: some small leeway, usually no more than a few minutes1. Set it explicitly to something like thirty seconds, and run NTP so you never need more.

Where the token lives

A JWT in localStorage is readable by every script on the page. Your analytics vendor, the chat widget, an A/B testing snippet, any XSS payload, any extension with host permissions. All of them can read it, and a bearer token works for whoever holds it.

An HttpOnly, Secure, SameSite cookie fixes the copying, not the abuse. Script that runs on your page can still make authenticated requests as the user, because the browser attaches the cookie. What it cannot do is read the token and send it somewhere for use tomorrow, from another machine, after you have patched the hole. That difference is worth the migration.

CSRF becomes your problem instead, handled with a separate token or the double-submit pattern. If you have inherited a localStorage system and cannot refactor auth this quarter, a strict Content Security Policy is the interim move. It does not make XSS safe, it makes exploitation more work.

Claims that should not be claims

The payload is readable by anyone who has the token, so it should contain the minimum needed to route a request: subject, issuer, audience, expiry, coarse scopes. Not internal identifiers you do not expose elsewhere, not personal data, not role names that describe your infrastructure. Everything else lives in the database and is loaded per request.

And validate iss and aud. RFC 7519 is direct about audience: if the principal processing the token does not identify itself in aud, the token must be rejected1. Two services sharing one signing key without audience checks means a token minted for the admin console is a valid token for the API.

jwt.verify(token, secret, {
  algorithms: ['HS256'],
  issuer: 'https://auth.example.com/',
  audience: 'https://api.example.com/',
});

The checklist

  1. Explicit algorithms on every verify, whatever your library's defaults are today.
  2. 256 bits of key material for HS256, from a CSPRNG, never a phrase.
  3. exp on every token, short, no exceptions for convenience.
  4. iss and aud validated, not merely present.
  5. clockTolerance set explicitly, measured in seconds.
  6. HttpOnly cookie storage unless you have a specific reason and a written mitigation.
  7. Payload trimmed to what routing needs.
  8. A revocation answer that exists before you need it.

For inspecting a token while debugging, the JWT Decoder runs locally and nothing is uploaded. The HMAC Generator covers HS256, HS384 and HS512 if you need to hand-check a signature. Anything beyond looking belongs in code, under test.

The recurring lesson is not that JWTs are dangerous. It is that "the library handles it" is a claim with a version number attached, and the version you are running is the only one that counts.

Sources

Every number in this article traces to a source below. Where a claim could not be sourced, it was cut rather than softened.

  1. Primary sourceIETF

    The definitions of the iss, aud and exp claims, that a JWT must be rejected if the principal is not identified in aud, and the guidance that implementers may allow small leeway for clock skew, usually no more than a few minutes.

  2. Primary sourceIETF

    Section 3.2, that a key at least the size of the hash output MUST be used with HMAC, and section 3.6, that implementations MUST NOT accept unsecured JWSs by default and should require per-object opt-in.

  3. Primary sourceAuth0

    The original disclosure of both the alg none bypass and the RS256 to HS256 confusion attack, including the explanation that a server expecting RSA will treat the public key as an HMAC secret.

  4. Primary sourceGitHub

    That when the algorithms option is omitted the library now derives an allowlist from the key type, and the description of clockTolerance as seconds of leeway on nbf and exp.

  5. Primary sourcehashcat

    That hash-mode 16500 is JWT (JSON Web Token), which is how a captured token becomes an offline cracking target.

Topics

Tools mentioned in this article

Get new tools by email

New tools and the occasional deep-dive, about once a month. No spam, no sharing your address, unsubscribe in one click.

Related articles

Article title card. Six small grey dots standing for a typed password beside a set of amber concentric rings, with the line: nothing to type, nothing to steal
Security & Privacy

Passkeys vs Passwords: How WebAuthn Actually Replaces the Password

How passkeys, FIDO2 and WebAuthn actually work, why origin scoping ends phishing, and the tradeoffs around recovery and syncing that still bite.

Article title card. A large pair of amber curly braces, with the line: one format, three dialects
Developer Tools Guide

The Developer's JSON Guide: Everything Worth Knowing in 2026

The spec, the dialects that disagree, the numbers that quietly break, and the security advice that points at the wrong recursion. With the cluster map.

Article title card. A shield with its right half filled in amber, with the line: what leaves the machine, and when
Security & Privacy Guide

Online Privacy Guide: Threat Models for Browser-Based Work

Threat models worth naming, the thirty-second check that beats any privacy policy, and an honest list of which of our own tools upload your file.