JWT Security Pitfalls That Will Get You Owned: alg=none, Key Confusion, Weak Secrets, and Replay Attacks

JWT is one of those technologies that looks dead simple on the surface and hides a minefield underneath. Decode a token, verify a signature, check the claims — how hard can it be? Turns out, hard enough that Auth0, Okta, and countless production APIs have shipped critical authentication bypasses because someone trusted the spec too literally or reached for the wrong library method.

This article isn’t a "JWT 101." It’s the part of the conversation that usually gets skipped: the specific ways tokens get forged, secrets get cracked, and sessions get hijacked. For each attack I’ll show you exactly how it works, point to real CVEs where applicable, and give you the code that closes the hole.

Official JWT spec lives at RFC 7519. The jsonwebtoken Node library is at https://github.com/auth0/node-jsonwebtoken. PyJWT is at https://github.com/jpadilla/pyjwt. Keep those open — we’ll be reading their source.


Quick anatomy recap

A JWT is three base64url-encoded chunks separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   ← header
.eyJzdWIiOiIxMjM0Iiwicm9sZSI6InVzZXIifQ  ← payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c  ← signature

The header tells the server which algorithm to use when verifying. That’s the root cause of two of the four attacks below. The server reads the header, then trusts it. What could go wrong?


Attack 1 — alg=none

How it works

The JWT spec explicitly includes "alg": "none" as a valid algorithm meaning "unsecured JWT — no signature required." Some early library implementations would accept this: parse the header, see none, skip signature verification entirely, and happily return the decoded payload as authentic.

An attacker doesn’t need your secret. They just forge a payload, set alg to none, drop the signature entirely (or leave it as an empty string), and submit:

import base64, json

header  = base64.urlsafe_b64encode(json.dumps({"alg":"none","typ":"JWT"}).encode()).rstrip(b"=")
payload = base64.urlsafe_b64encode(json.dumps({"sub":"1337","role":"admin"}).encode()).rstrip(b"=")

forged_token = f"{header.decode()}.{payload.decode()}."
print(forged_token)

If the server accepts this, authentication is gone. Completely.

Who got hit

CVE-2015-9235 (node-jsonwebtoken < 4.2.2), CVE-2016-5431 (python-jose), and a pile of unlisted library bugs across Ruby, PHP, and Go ecosystems. This was so widespread that in 2015 the JOSE working group had to publish a separate security advisory telling everyone to explicitly reject none.

The fix

Never call a verify function that lets the algorithm come from the token. Always specify the expected algorithm explicitly on the server side:

// WRONG — the library reads alg from the token header
jwt.verify(token, secret);

// RIGHT — you dictate the algorithm; the library rejects anything else
jwt.verify(token, secret, { algorithms: ['HS256'] });
# PyJWT — same principle
import jwt

# WRONG
decoded = jwt.decode(token, secret, algorithms=jwt.algorithms.get_default_algorithms())

# RIGHT
decoded = jwt.decode(token, secret, algorithms=["HS256"])

Gotcha: Some wrappers still default to accepting whatever algorithm the token claims. Always read the verify signature of your specific library version. If the algorithms parameter is optional with a permissive default, treat that as a bug and pin it anyway.


Attack 2 — Key Confusion (RS256 → HS256)

This one is more subtle and more devastating, because it only affects systems using asymmetric signing — which is supposed to be more secure.

How it works

RS256 uses a private key to sign and a public key to verify. The public key is, by design, public. You might expose it at /.well-known/jwks.json, embed it in a README, or ship it in your frontend bundle.

Now here’s the confusion: HS256 uses a symmetric key — the same secret for signing and verifying. If a vulnerable library sees "alg": "HS256" in the header, it calls its verify routine with a single secret. If that library doesn’t lock down which algorithm is expected, an attacker can:

  1. Grab your RS256 public key (it’s public, so trivial).
  2. Craft a token with "alg": "HS256" in the header.
  3. Sign it using the public key as the HMAC secret.
  4. Submit it to your server.

The server was configured for RS256 and holds the public key. The library sees alg=HS256, treats the public key as an HMAC secret, verifies the HMAC signature — and it matches. Auth bypass, full stop.

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
import jwt, requests

# Step 1: grab the server's public key
jwks = requests.get("https://target.example.com/.well-known/jwks.json").json()
# (parse the JWK into PEM — omitted for brevity, use joserfc or python-jose to do this)
public_key_pem = b"-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"

# Step 2: sign with HS256 using the public key as the HMAC secret
forged = jwt.encode(
    {"sub": "9999", "role": "admin"},
    public_key_pem,
    algorithm="HS256"
)

# Step 3: send to server — if it's vulnerable, this authenticates as admin

The fix

Same answer: pin the algorithm server-side. Never let the token’s header decide what algorithm runs.

// For an RS256-only service:
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
// If someone submits alg=HS256, this throws immediately — done.

Production pattern: If you’re building an internal microservice mesh, consider banning algorithm agility entirely. Pick one algorithm per service, hard-code it, and refuse to handle anything else. The flexibility the spec provides is mostly theoretical — you almost never need to rotate from RS256 to ES256 at runtime without a deployment.

Gotcha: JWKS endpoints sometimes serve multiple keys for rotation purposes. Your library must match the kid (key ID) in the token header to the correct key in the set — not just try all keys until one works. Trying all keys with all algorithms is a brute-force assist for an attacker.


Attack 3 — Weak Secrets

HS256, HS384, HS512 are only as strong as the secret used. If you can recover the secret, you can forge any token.

How weak is "weak"?

In real breach data I’ve seen:

  • secret — yes, literally the word "secret"
  • your-256-bit-secret — copied from the jsonwebtoken README and never changed
  • jwt_secret or JWT_SECRET_KEY
  • Company name + year: acme2023
  • Environment-specific: dev, test, staging
  • Short random strings under 16 bytes

HMAC-SHA256 is fast. On a modern GPU you can test tens of millions of guesses per second with hashcat using mode -m 16500 (JWT/JWA).

# Crack a HS256 JWT with hashcat
hashcat -a 0 -m 16500 target.jwt /usr/share/wordlists/rockyou.txt

# With rules for mangling
hashcat -a 0 -m 16500 target.jwt /usr/share/wordlists/rockyou.txt -r /usr/share/hashcat/rules/best64.rule

If the secret appears anywhere in rockyou.txt or common wordlists, it’s gone in seconds. If it’s under 10 random characters, brute force with a charset finishes in minutes on commodity hardware.

jwt_tool (https://github.com/ticarpi/jwt_tool) automates both cracking and exploitation in one CLI — worth having in your testing toolkit.

The fix

Secrets for HMAC JWTs should be cryptographically random and at least 256 bits (32 bytes) long. The spec says so explicitly; people ignore it.

# Generate a production-grade secret
openssl rand -hex 32
# or
python3 -c "import secrets; print(secrets.token_hex(32))"

Store it in a secrets manager (HashiCorp Vault, AWS Secrets Manager, Doppler) — not in .env files committed to git, not in application config checked into version control.

Better option for new systems: Use RS256 or ES256. With asymmetric signing there is no shared secret to crack. The private key signs tokens on the auth server; the public key verifies them everywhere else. Even if an attacker gets a token, there’s nothing to crack.

Gotcha: Rotating a compromised HMAC secret invalidates all existing tokens immediately, which is operationally painful. Plan for this before you need it. With asymmetric keys, rotation is cleaner — publish the new public key alongside the old one in JWKS, with the old key staying valid until its exp window closes.


Attack 4 — Replay Attacks

JWT is stateless. The server doesn’t store issued tokens anywhere — it just verifies the signature and trusts the claims. That’s the entire value proposition. It’s also how replay attacks happen.

The scenario

A valid token gets intercepted (network sniffing on HTTP, shoulder surfing, XSS pulling from localStorage, a compromised CDN log). The attacker now has a token that is cryptographically valid and will continue to be accepted until it expires.

If your tokens have a 7-day expiry (common "refresh-token-lite" setups), that’s a 7-day window of full account access with a stolen credential that you have no way to revoke.

Even short-lived access tokens (15-30 minutes) create a window. If the user just changed their password or explicitly logged out, the stolen token is still valid.

Fix 1 — Short expiry + refresh token rotation

Access tokens: 5-15 minutes. Refresh tokens: stored server-side in a database with a one-time-use constraint.

// On refresh token use:
// 1. Validate the refresh token signature
// 2. Look it up in the DB — if not found, STOP (already used = possible replay)
// 3. Delete it from the DB immediately
// 4. Issue a new access token + new refresh token
// 5. Store the new refresh token in the DB

const used = await db.refreshTokens.delete({ token: incomingRefreshToken });
if (!used) {
  // Token already consumed — possible replay attack
  // Optionally: invalidate ALL tokens for this user (assume compromise)
  throw new AuthError("Refresh token already used");
}
const newAccessToken  = signAccessToken(userId);
const newRefreshToken = signRefreshToken(userId);
await db.refreshTokens.insert({ token: newRefreshToken, userId, expiresAt });

Fix 2 — jti claim + server-side blocklist

Every issued token gets a unique ID (jti). On logout or password change, add that jti to a Redis blocklist with a TTL matching the token’s remaining lifetime.

import redis, jwt, uuid

r = redis.Redis()

def issue_token(user_id: str) -> str:
    jti = str(uuid.uuid4())
    payload = {
        "sub": user_id,
        "jti": jti,
        "exp": ...,
    }
    return jwt.encode(payload, PRIVATE_KEY, algorithm="RS256")

def verify_token(token: str) -> dict:
    claims = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])
    
    # Check blocklist
    if r.get(f"blocklist:{claims['jti']}"):
        raise ValueError("Token has been revoked")
    
    return claims

def revoke_token(jti: str, remaining_ttl_seconds: int):
    r.setex(f"blocklist:{jti}", remaining_ttl_seconds, "1")

Gotcha: This re-introduces statefulness. You’re trading the simplicity of stateless JWT for revocation capability. That’s a conscious, worthwhile tradeoff — just be aware of it. If your Redis goes down, you either fail open (accept all tokens, dangerous) or fail closed (reject all tokens, service outage). Plan your availability story accordingly.

Fix 3 — Sender-constrained tokens (DPoP)

OAuth 2.0 DPoP (Demonstrating Proof of Possession, RFC 9449) binds a token to a client’s private key. A stolen token is useless without the corresponding private key. This is the correct long-term answer for high-security APIs, though it adds client-side complexity. Worth it for anything financial or healthcare-adjacent.


Defense in Depth — the quick checklist

These apply regardless of which attacks you’re worried about:

Algorithm pinning — always pass algorithms=["HS256"] (or RS256, ES256) to your verify call. Never let the header decide.

Secret hygiene — 32+ random bytes, in a secrets manager, rotated on any suspected exposure. If you’re on HS256 and your secret hasn’t been rotated in 18 months, do it this week.

Short expiry — access tokens under 15 minutes. Refresh tokens one-time-use with server-side tracking.

nbf and iat validation — check them. A token with iat in the far future or nbf not yet valid should be rejected, not accepted.

Audience (aud) validation — if you have multiple services, set aud per service and validate it. A token issued for your payments service shouldn’t be accepted by your admin service.

HTTPS only — obvious but worth saying: tokens in transit must be over TLS. Secure + HttpOnly cookies if you’re doing browser auth; never localStorage for sensitive tokens.

Library version — run npm audit or pip-audit regularly. The alg=none and key confusion fixes required library updates, not application code changes. Staying on old versions means staying vulnerable.


Closing thought

Most JWT vulnerabilities aren’t clever 0-days — they’re the spec being too permissive and libraries implementing that permissiveness faithfully. The fixes are almost all one-liners: pin the algorithm, use a real secret, keep tokens short-lived, and validate every claim explicitly.

The trap is treating JWT as "just a token format" and reaching for verify(token, secret) without reading what the function actually accepts. Read your library’s docs once, properly. That’s the whole job.

Leave a comment

👁 Views: 6,725 · Unique visitors: 10,676