Skip to main content
Back to blog
April 28, 2026|8 min read

JWT Security Best Practices: Vulnerabilities & Safe Implementation

JWT vulnerabilities have led to authentication bypasses in major applications. This guide covers every known attack and how to write secure token handling code.

ZeriFlow Team

1,326 words

JWT Security Best Practices: Vulnerabilities & Safe Implementation

JWT security failures are responsible for some of the most severe authentication bypasses ever published. Despite being widely adopted, JSON Web Tokens are frequently misconfigured in ways that allow complete authentication bypass. This guide covers every significant vulnerability class and the code patterns that prevent them.

Check your site's security configuration: Free ZeriFlow scan in 60 seconds →

JWT Structure: What You Are Actually Signing

A JWT is three base64url-encoded segments joined by dots: header.payload.signature.

The header specifies the algorithm. The payload carries claims. The signature is computed over the header and payload using a secret or private key. When the server receives a JWT, it recomputes the signature and compares — if they match, the token is accepted.

This design creates a critical dependency: the security of the entire token rests on (1) the algorithm being correctly enforced and (2) the key being secret and strong.

Vulnerability 1: The "none" Algorithm Attack

The most infamous JWT vulnerability: if a library trusts the alg field in the header without validation, an attacker can modify it to none, remove the signature, and the library accepts the token as valid.

javascript
// Attacker constructs this token:
// Header: {"alg":"none","typ":"JWT"}
// Payload: {"userId":1,"role":"admin","iat":...}
// Signature: (empty)

// Result: "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9."

Fix: always specify the expected algorithm explicitly. Never accept none.

javascript
// WRONG — vulnerable
const payload = jwt.verify(token, secret); // trusts alg header

// CORRECT — explicitly specify algorithm
const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });
// For RS256:
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
python
import jwt

# WRONG
payload = jwt.decode(token, secret, options={"verify_signature": False})

# CORRECT
payload = jwt.decode(
    token,
    secret,
    algorithms=["HS256"]  # explicit whitelist
)

Vulnerability 2: Weak HMAC Secrets

HS256 uses a symmetric secret to both sign and verify tokens. If the secret is weak, attackers can brute-force it offline once they obtain any valid token. Tools like hashcat can test billions of candidates per second against HS256.

Observed in the wild: - secret — the literal string - password - Application name or domain - Secrets under 32 characters - Secrets derived from predictable information (database name, hostname)

Fix: use a cryptographically random secret of at least 256 bits.

javascript
const crypto = require('crypto');
// Generate once, store in secrets manager
const JWT_SECRET = crypto.randomBytes(64).toString('hex');
// Result: 128-char hex string = 512 bits

For multi-service architectures, prefer RS256 (asymmetric). Services can verify tokens using the public key without ever having the private key. A compromised downstream service cannot forge tokens.

javascript
// RS256 — signing (auth service only)
const fs = require('fs');
const privateKey = fs.readFileSync('private.pem');

function createToken(userId, role) {
  return jwt.sign(
    { userId, role },
    privateKey,
    {
      algorithm: 'RS256',
      expiresIn: '15m',
      issuer: 'auth.yourapp.com',
      audience: 'api.yourapp.com'
    }
  );
}

// RS256 — verification (any service)
const publicKey = fs.readFileSync('public.pem');

function verifyToken(token) {
  return jwt.verify(token, publicKey, {
    algorithms: ['RS256'],
    issuer: 'auth.yourapp.com',
    audience: 'api.yourapp.com'
  });
}

Vulnerability 3: Algorithm Confusion (RS256 → HS256)

Some libraries accept both HMAC and RSA algorithms. An attacker can take an RS256 token, change the header to HS256, and sign it with the server's *public key* (which is often published). If the library uses the public key as the HMAC secret when it sees alg: HS256, the forged token verifies successfully.

Fix: strictly whitelist the expected algorithm. If you deploy RS256, only accept RS256.

javascript
// WRONG — accepts both
jwt.verify(token, keyOrSecret, { algorithms: ['RS256', 'HS256'] });

// CORRECT — accept only what you issue
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Vulnerability 4: Missing Expiration and Claims Validation

A JWT without an expiration (exp claim) is valid forever. A stolen token from a user who logged out 6 months ago still grants access.

Always include:

javascript
const token = jwt.sign(
  {
    sub: userId,          // subject
    iat: Math.floor(Date.now() / 1000), // issued at
    jti: crypto.randomUUID(), // JWT ID — enables revocation
  },
  privateKey,
  {
    algorithm: 'RS256',
    expiresIn: '15m',      // short-lived access token
    issuer: 'auth.yourapp.com',
    audience: 'api.yourapp.com'
  }
);

// Verification must check all claims
jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'auth.yourapp.com',
  audience: 'api.yourapp.com',
  clockTolerance: 30        // allow 30s clock skew
});

Token Revocation

JWTs are stateless by design — the server does not store them, so it cannot "invalidate" one. This is a deliberate trade-off that causes problems when:

  • A user logs out and you want the token to stop working.
  • A user changes their password after a compromise.
  • An admin needs to forcibly terminate a session.

Solutions ranked by complexity:

Option 1 — Short expiry + refresh tokens: issue 15-minute access tokens. Stealing one gives access for 15 minutes maximum. Refresh tokens (long-lived, stored server-side) are exchanged for new access tokens and can be revoked.

Option 2 — Blocklist by `jti`: maintain a Redis set of revoked token IDs. Check on every request. Scales well with TTL equal to token expiry.

javascript
const revokedTokens = new Set(); // use Redis in production

async function revokeToken(token) {
  const decoded = jwt.decode(token);
  await redis.setEx(`revoked:${decoded.jti}`, decoded.exp - Math.floor(Date.now()/1000), '1');
}

async function isRevoked(jti) {
  return await redis.exists(`revoked:${jti}`) === 1;
}

// In verification middleware
async function authMiddleware(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  try {
    const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
    if (await isRevoked(payload.jti)) {
      return res.status(401).json({ error: 'Token has been revoked' });
    }
    req.user = payload;
    next();
  } catch (err) {
    res.status(401).json({ error: 'Invalid token' });
  }
}

ZeriFlow verifies the security headers your application serves — including Content-Security-Policy, which controls where tokens stored in localStorage can be sent, and CORS headers that prevent cross-origin token theft. Misconfigured CORS combined with a misconfigured JWT library is a common path to full authentication bypass.


FAQ

### Q: Should I store JWTs in localStorage or cookies? A: Cookies with HttpOnly and Secure flags. LocalStorage is accessible to any JavaScript on the page — XSS immediately leads to token theft. HttpOnly cookies cannot be read by JavaScript, limiting XSS impact to same-session requests only.

### Q: What is the right expiry time for JWTs? A: Access tokens: 15 minutes. Refresh tokens: 7-30 days depending on your security requirements, stored server-side with revocation capability. Never issue access tokens that last longer than an hour.

### Q: Can I use JWT for session management? A: You can, but traditional server-side sessions are often simpler and safer for standard web apps. JWTs shine in multi-service architectures where you want stateless verification. For single-server apps, a session cookie with a database-backed session store is easier to secure and revoke.

### Q: How do I rotate JWT signing keys? A: Publish multiple public keys (a JWKS endpoint) and include a kid (key ID) claim in your tokens. Rotate by adding a new key to the JWKS, updating issuance to use the new key, and removing the old key after all tokens signed with it have expired.

### Q: Is RS256 always better than HS256? A: For single-service apps, HS256 with a strong secret is fine. For distributed systems where multiple services verify tokens, RS256 is strongly preferred — a compromised service cannot forge new tokens since it only has the public key.


Conclusion

JWT security comes down to: explicit algorithm whitelisting, cryptographically strong secrets or asymmetric keys, short token lifetimes, full claims validation, and a revocation strategy. Avoid trusting the alg header blindly, avoid weak secrets, and never skip expiry. These five rules prevent every major JWT vulnerability class.

Run a free ZeriFlow scan → — no credit card required.

Ready to check your site?

Run a free security scan in 30 seconds.

Related articles

Keep reading