Authentication

JWT Security Best Practices: Common Vulnerabilities and How to Fix Them

JSON Web Tokens power authentication in millions of applications, but subtle implementation mistakes lead to critical vulnerabilities. Learn the most dangerous JWT flaws and how to eliminate them.

August 1, 20258 min readShipSafer Team

JSON Web Tokens (JWTs) have become the de facto standard for stateless authentication in modern web applications. Their self-contained nature makes them attractive: no server-side session storage, easy horizontal scaling, and a clean interface for API authentication. But that convenience comes with sharp edges. Misconfigured JWTs are responsible for a long list of breaches, and the mistakes are surprisingly easy to make.

This post walks through the most dangerous JWT vulnerabilities, how attackers exploit them, and the concrete steps you can take to fix each one.

Understanding JWT Structure

Before diving into vulnerabilities, a quick refresher. A JWT consists of three Base64url-encoded parts separated by dots:

header.payload.signature

The header declares the algorithm. The payload carries claims (user ID, roles, expiry). The signature proves the token hasn't been tampered with — but only if the algorithm and secret are handled correctly.

The alg:none Attack

This is one of the most notorious JWT vulnerabilities. The JWT specification allows an alg value of none, meaning an unsigned token. A number of early JWT libraries would accept tokens with "alg": "none" even when the application expected signed tokens.

Attack scenario:

  1. Attacker receives a valid JWT signed with HS256.
  2. Attacker decodes the header and payload (no secret needed — it's just Base64).
  3. Attacker modifies the payload (e.g., "role": "admin").
  4. Attacker re-encodes with "alg": "none" and sends an empty signature.
  5. Vulnerable library accepts the token.

The fix: Explicitly specify which algorithms your verification code accepts. Never allow the algorithm to be determined by the token itself.

// VULNERABLE: trusts the alg header in the token
jwt.verify(token, secret);

// SECURE: explicitly allow only the expected algorithm
jwt.verify(token, secret, { algorithms: ['HS256'] });

// For RS256/ES256 (asymmetric keys)
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Most modern libraries have patched this, but old installations and certain configurations still fall through.

Weak Signing Secrets

HS256, HS384, and HS512 use a shared secret for both signing and verification. If that secret is short, guessable, or derived from predictable values, an attacker can brute-force it offline using a captured token.

Common offenders:

  • secret, password, jwt_secret
  • The application name or domain
  • Reused database passwords
  • Secrets committed to version control

The fix: Generate a cryptographically random secret of at least 256 bits (32 bytes).

# Generate a secure random secret
openssl rand -base64 32
# Output: something like: K3pZ8mRqLw...

# In Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

Store this in your environment variables, never in source code. For sensitive applications, prefer asymmetric keys (RS256 or ES256) so verification servers never need the private signing key.

Storing JWTs in localStorage vs HttpOnly Cookies

Where you store a JWT matters as much as how you generate it.

localStorage vulnerabilities: Any JavaScript on the page can read localStorage. This includes third-party scripts, browser extensions injected into the page, and XSS payloads. Once an attacker achieves XSS, they can exfiltrate every token in localStorage.

HttpOnly cookies cannot be read by JavaScript at all. The browser attaches them to requests automatically, and they're invisible to any script running on the page.

// VULNERABLE: token accessible to any script
localStorage.setItem('authToken', token);

// SECURE: HttpOnly cookie set server-side (Node.js/Express example)
res.cookie('authToken', token, {
  httpOnly: true,        // Not accessible via JavaScript
  secure: true,          // HTTPS only
  sameSite: 'lax',       // CSRF protection
  maxAge: 7 * 24 * 60 * 60 * 1000  // 7 days
});

The tradeoff is CSRF risk — mitigated by sameSite: 'lax' or 'strict' and additional CSRF token validation for state-changing requests.

Token Expiry and Refresh Token Rotation

JWTs are stateless, which means there's no built-in revocation mechanism. A stolen token is valid until it expires. Short-lived access tokens limit the damage window.

Recommended TTLs:

  • Access tokens: 15 minutes
  • Refresh tokens: 7–30 days

Refresh token rotation means issuing a new refresh token every time an access token is refreshed, and invalidating the old one. This detects token theft: if an attacker uses a refresh token, the legitimate user's next refresh attempt will fail (the token was already rotated), alerting the system.

// Refresh token rotation flow
async function refreshAccessToken(refreshToken) {
  const stored = await db.findRefreshToken(refreshToken);

  if (!stored || stored.used || stored.expiresAt < Date.now()) {
    // If token was already used, possible theft — invalidate entire family
    if (stored?.used) {
      await db.invalidateTokenFamily(stored.familyId);
    }
    throw new Error('Invalid refresh token');
  }

  // Mark old token as used
  await db.markRefreshTokenUsed(refreshToken);

  // Issue new access + refresh token pair
  const newAccessToken = generateAccessToken(stored.userId);
  const newRefreshToken = await generateRefreshToken(stored.userId, stored.familyId);

  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

Leaking Sensitive Data in the Payload

JWT payloads are Base64-encoded, not encrypted. Anyone who intercepts a JWT — through logs, error messages, browser history, or network inspection — can decode and read the payload.

Never include in JWT payloads:

  • Passwords or password hashes
  • Social Security numbers, credit card numbers
  • API keys or internal secrets
  • Sensitive medical or financial data
  • Internal system details an attacker could exploit
// VULNERABLE: too much information in payload
const payload = {
  userId: user.id,
  email: user.email,
  passwordHash: user.passwordHash,  // NEVER
  ssn: user.ssn,                    // NEVER
  internalRole: 'super_admin',      // Reveals internal structure
  dbConnectionString: '...',        // Absolutely never
};

// SECURE: minimal claims needed for authorization
const payload = {
  sub: user.userId,         // Subject (standard claim)
  role: user.role,          // Only what's needed for authz decisions
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + (15 * 60),  // 15 min
};

If you need to encrypt the payload (JWT with encryption is called JWE), use a library that supports it, but most applications don't need full encryption — they just need to stop putting sensitive fields in the payload.

Algorithm Confusion: HS256 vs RS256

A related attack targets applications that switch between symmetric (HS256) and asymmetric (RS256) algorithms. If a server uses RS256 to sign tokens with a private key and verifies with the corresponding public key — and that public key is publicly accessible — an attacker can:

  1. Obtain the public key.
  2. Use it as an HS256 symmetric secret to sign a forged token.
  3. Send the token with "alg": "HS256".
  4. A vulnerable verifier using the public key as an HMAC secret will accept it.

The fix is the same: always specify the allowed algorithm explicitly in your verification call, never derive it from the token header.

Token Revocation Strategies

Pure stateless JWTs can't be revoked. For high-security applications, you need a hybrid approach:

Denylist (blocklist): Maintain a Redis set of revoked token JTI (JWT ID) values. Check this on every request. This adds a network round-trip but allows immediate revocation.

// Mint token with a unique JTI
const token = jwt.sign(
  { sub: userId, jti: nanoid(), ...otherClaims },
  secret,
  { expiresIn: '15m' }
);

// On verification, check the denylist
async function verifyToken(token) {
  const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });

  const isRevoked = await redis.sismember('revoked_tokens', decoded.jti);
  if (isRevoked) throw new Error('Token has been revoked');

  return decoded;
}

// On logout or security event
async function revokeToken(jti, expiresAt) {
  // Only store until token natural expiry to keep denylist bounded
  const ttl = Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
  await redis.setex(`revoked:${jti}`, ttl, '1');
}

Audit Your JWT Implementation

Beyond individual settings, audit your entire JWT lifecycle:

  1. Issuance: Only issue tokens after successful authentication. Include an expiry (exp) and issuance time (iat) in every token.
  2. Transmission: Tokens should only travel over HTTPS. Never log tokens.
  3. Verification: Validate algorithm, signature, expiry, issuer (iss), and audience (aud).
  4. Storage: HttpOnly cookies on the web; secure storage APIs on mobile.
  5. Revocation: Have a plan for revoking tokens when accounts are compromised.

Summary

VulnerabilityFix
alg:noneSpecify allowed algorithms explicitly in verify call
Weak secretUse 256-bit+ random secret or RS256/ES256
localStorage storageUse HttpOnly, Secure, SameSite cookies
Long expiry15-minute access tokens + refresh token rotation
Sensitive payload dataNever put PII or secrets in the payload
Algorithm confusionExplicitly whitelist allowed algorithms
No revocationImplement JTI denylist for high-value tokens

JWTs are a powerful tool, but they require careful handling at every step. The good news is that each of these vulnerabilities has a clear, implementable fix. Run through this checklist on your current implementation and you'll eliminate the most common sources of JWT-related breaches.

jwt
authentication
security
api security
tokens

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.