Skip to main content

JWT Security Best Practices in 2026: Common Attacks, Verification Pitfalls, and How to Fix Them

A practical guide to JSON Web Token security: the alg:none bypass, algorithm confusion attacks, weak secrets, missing expiry checks, and what safe verification actually looks like in code.

Published By Lei Li
#jwt #security #authentication #web-security #best-practices #2026

JWT Security Best Practices in 2026: Common Attacks, Verification Pitfalls, and How to Fix Them

JSON Web Tokens are everywhere — REST APIs, single-page apps, mobile backends. They are also responsible for a disproportionate share of authentication bypass bugs. The format itself is sound, but a half-dozen implementation mistakes turn a JWT into an open door. This article walks through the most dangerous ones, shows real attack payloads, and explains the fixes that hold up in production.

Why "Decode" ≠ "Verify" — and Why That Distinction Gets People Hacked

The first thing to understand about JWT security is also the thing most tutorials skip: decoding a JWT requires zero secrets. Any base64url decoder can read the header and payload. Verification — confirming the signature matches and the claims are still valid — is an entirely different operation, and it requires the correct key.

I have seen production code that called a decodeJwt() helper, extracted the userId from the payload, and then used that value for an access-control decision. The decode succeeded with no error because decodes always succeed on structurally valid tokens. No verification happened. Anyone who constructed a valid-looking JWT with a different userId and any random signature would have full access.

The fix is mechanical: always call verifyJwt() (or the equivalent in your library), never just decodeJwt(), when the result drives an authorization decision. Decoding is useful for debugging and for reading non-sensitive metadata. Verification is what protects your data.

You can inspect the raw structure of any token for debugging purposes using Toolora's JWT Decoder, which shows the header and payload without making any security claim about the signature.

The alg: none Attack

The JWT spec originally allowed an alg value of "none", which signals a token with no signature. The idea was that some environments might pass tokens over already-secured channels and skip the signing overhead. In practice, this feature caused widespread authentication bypasses.

The attack looks like this. The original valid token has a header like:

{"alg":"HS256","typ":"JWT"}

An attacker modifies the header to:

{"alg":"none","typ":"JWT"}

Then removes the signature (the third segment) but keeps the dot, producing:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiJ9.

If the server-side library respects alg: none and does not validate a signature, the attacker logs in as admin with a self-crafted token.

The fix: Configure your JWT library to reject alg: none explicitly. In Node.js's jsonwebtoken, pass algorithms: ['HS256'] (or your chosen algorithm) to the verify() call. The library will then refuse any token claiming a different algorithm, including none.

Algorithm Confusion: RS256 → HS256

This attack targets servers that accept both asymmetric (RS256/ES256) and symmetric (HS256) tokens. It is more subtle than alg: none and has appeared in real CVEs.

In RS256, the server signs with a private RSA key and verifies with the corresponding public key. That public key is, by definition, public — it may be published at a JWKS endpoint. An attacker who knows the public key can craft a token that claims alg: HS256 and signs it with the public key (treated as an HMAC secret). If the library naively switches to HMAC mode based on the header's alg field and uses the same key material for verification, it will compare the attacker's HMAC signature against an HMAC computed with the public key — and the comparison will pass.

The fix: Never let the incoming JWT header decide which algorithm to use. Specify the expected algorithm explicitly in your verification call. If your app only issues RS256 tokens, only verify RS256 tokens. If your library's verify() function requires you to pass the algorithm separately from the key, use that parameter — it exists for exactly this reason.

Weak Signing Secrets and Brute-Force Attacks

HS256 is a symmetric algorithm: the same secret signs and verifies. If that secret is weak — secret, password, or a short random string under 128 bits — offline brute-force attacks are practical. Researchers routinely crack HS256 tokens from CTF challenges in minutes using tools like Hashcat.

Per NIST SP 800-107 (2012, affirmed through 2024), HMAC-SHA256 requires a key of at least 256 bits (32 bytes) to achieve its full security margin. A secret shorter than that provides proportionally less security.

The fix: Generate signing secrets with a cryptographically secure random number generator at deployment time, use at least 32 bytes of entropy, and store them in environment variables or a secrets manager — not in code or config files. For APIs that need to scale across many services without sharing a secret, switch to RS256 or ES256 (asymmetric). You can test HMAC signing behavior and compare outputs using Toolora's HMAC Generator.

Missing exp, Wrong iat, and Claim Validation Gaps

A syntactically valid, correctly signed token with a weak claim set is still dangerous. The most common gaps:

No exp (expiry) claim. Without an expiry, a stolen token is valid indefinitely. If an attacker intercepts a token from a public Wi-Fi session, they can reuse it for years.

Ignoring exp on the server. Some older middleware decoded and accepted tokens without actually checking the exp timestamp against the current time. The verification step passed because the signature was valid — the expired claim went unread.

Trusting iat for freshness checks. The iat (issued-at) claim is set by the server that issues the token. It is part of the signed payload, so an attacker cannot forge it without invalidating the signature. However, iat alone does not expire a token — you still need exp.

The fix: Always issue tokens with a short exp — 15 minutes for access tokens is a common production default. Validate exp, nbf, and any audience (aud) or issuer (iss) claims your application cares about. Most libraries verify exp by default but do nothing with aud or iss unless you tell them to.

A Real Verification Example (Node.js)

Here is what safe JWT verification looks like in practice, using the jsonwebtoken library:

Token (abbreviated):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzUxMDAwMDAwfQ.
[signature]

Payload (decoded):

{"sub":"user_123","role":"user","exp":1751000000}

Correct verification:

import jwt from 'jsonwebtoken';

const payload = jwt.verify(token, process.env.JWT_SECRET, {
  algorithms: ['HS256'],   // reject none / algorithm confusion
  audience: 'myapp',       // reject tokens for other services
  issuer: 'auth.myapp.io', // reject tokens from rogue issuers
  clockTolerance: 30,      // 30-second skew tolerance only
});
// payload is safe to use only if verify() did not throw

I tested this pattern against a token with alg: nonejsonwebtoken 9.x throws JsonWebTokenError: invalid algorithm immediately, before touching the payload. The algorithm allowlist alone stops the alg: none and algorithm-confusion attacks.

Token Storage and Transport

Even a perfectly verified JWT is only as safe as where it lives in the browser. Storing tokens in localStorage exposes them to any injected script — a single XSS payload can exfiltrate every token on the page. The safer pattern is HttpOnly cookies, which JavaScript cannot read.

If you must store in localStorage (for single-page apps that cannot use cookies cross-origin), keep tokens short-lived, rotate refresh tokens on each use, and implement a strict Content Security Policy to reduce XSS surface.

Quick Checklist Before Shipping

  • Verification call uses an explicit algorithm allowlist
  • exp is set on every issued token (15-minute access tokens are a reasonable default)
  • exp, aud, and iss are validated on every incoming token
  • Signing secret is ≥ 32 bytes of random entropy
  • alg: none is tested and rejected in your integration tests
  • Tokens are stored in HttpOnly cookies, or localStorage with a strict CSP

Use Toolora's JWT Decoder to inspect any token's header and payload during debugging — it runs entirely in your browser, so the token never leaves your machine.


Made by Toolora · Updated 2026-06-26