JWT Security: Token Validation and Best Practices

JSON Web Tokens have become the de facto standard for stateless authentication, but their widespread adoption has also made them a prime target for attackers. Understanding JWT structure is essential...

Key Insights

  • Always validate JWT signatures with explicit algorithm whitelisting—never trust the token’s header to specify the algorithm, as this opens the door to algorithm confusion attacks.
  • Token validation extends far beyond signature verification; you must validate timing claims (exp, iat, nbf), audience, and issuer on every request.
  • Prefer asymmetric algorithms (RS256/ES256) for distributed systems and implement proper key rotation via JWKS endpoints to avoid secret management nightmares.

Introduction to JWT Security Risks

JSON Web Tokens have become the de facto standard for stateless authentication, but their widespread adoption has also made them a prime target for attackers. Understanding JWT structure is essential before diving into security practices.

A JWT consists of three Base64URL-encoded parts separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Decoded, this reveals:

// Header
{
  "alg": "HS256",
  "typ": "JWT"
}

// Payload
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

// Signature (binary data)
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

The most dangerous vulnerabilities stem from three areas: algorithm confusion attacks (where an attacker manipulates the alg header), weak or leaked secrets, and improper token storage leading to theft. Each of these can result in complete authentication bypass.

Signature Validation Essentials

The cardinal rule of JWT security: never trust any claim in the token until you’ve verified the signature. The signature proves the token hasn’t been tampered with and was issued by a trusted party.

The most critical mistake developers make is allowing the token itself to dictate which algorithm to use for verification. This enables the infamous “none” algorithm attack and algorithm confusion attacks where an attacker switches from RS256 to HS256, using the public key as an HMAC secret.

import jwt
from jwt.exceptions import InvalidTokenError, InvalidAlgorithmError

def validate_token(token: str, secret: str) -> dict:
    """
    Validate JWT with explicit algorithm whitelisting.
    NEVER allow the token to specify its own algorithm.
    """
    try:
        # Explicitly specify allowed algorithms - this is non-negotiable
        payload = jwt.decode(
            token,
            secret,
            algorithms=["HS256"],  # Whitelist only what you use
            options={
                "require": ["exp", "iat", "sub"],  # Require essential claims
                "verify_signature": True,
                "verify_exp": True,
                "verify_iat": True,
            }
        )
        return payload
    except InvalidAlgorithmError:
        raise ValueError("Token uses unauthorized algorithm")
    except InvalidTokenError as e:
        raise ValueError(f"Token validation failed: {str(e)}")

In Node.js, the same principle applies:

const jwt = require('jsonwebtoken');

function validateToken(token, secret) {
  try {
    // Always specify algorithms explicitly
    const payload = jwt.verify(token, secret, {
      algorithms: ['HS256'], // Never omit this
      complete: false,
    });
    return payload;
  } catch (error) {
    if (error.name === 'JsonWebTokenError') {
      throw new Error(`Invalid token: ${error.message}`);
    }
    throw error;
  }
}

Claims Validation

Signature verification confirms authenticity, but claims validation ensures the token is appropriate for the current context. Skipping claims validation is like checking someone’s ID is real but not checking if it’s expired.

from datetime import datetime, timezone
from typing import Optional

class JWTClaimsValidator:
    def __init__(
        self,
        expected_issuer: str,
        expected_audience: str,
        clock_skew_seconds: int = 30
    ):
        self.expected_issuer = expected_issuer
        self.expected_audience = expected_audience
        self.clock_skew = clock_skew_seconds

    def validate(self, claims: dict) -> None:
        """
        Comprehensive claims validation with clear error messages.
        """
        now = datetime.now(timezone.utc).timestamp()

        # Validate expiration (exp)
        exp = claims.get('exp')
        if exp is None:
            raise ValueError("Token missing expiration claim")
        if now > exp + self.clock_skew:
            raise ValueError("Token has expired")

        # Validate issued-at (iat)
        iat = claims.get('iat')
        if iat is None:
            raise ValueError("Token missing issued-at claim")
        if iat > now + self.clock_skew:
            raise ValueError("Token issued in the future")

        # Validate not-before (nbf) if present
        nbf = claims.get('nbf')
        if nbf is not None and now < nbf - self.clock_skew:
            raise ValueError("Token not yet valid")

        # Validate issuer
        if claims.get('iss') != self.expected_issuer:
            raise ValueError(f"Invalid issuer: {claims.get('iss')}")

        # Validate audience (can be string or list)
        aud = claims.get('aud')
        if isinstance(aud, list):
            if self.expected_audience not in aud:
                raise ValueError("Token not intended for this audience")
        elif aud != self.expected_audience:
            raise ValueError("Token not intended for this audience")

The clock skew allowance is important for distributed systems where server clocks may drift slightly. Thirty seconds is a reasonable default—long enough to handle minor drift, short enough to limit replay windows.

Secret and Key Management

For simple single-service applications, symmetric algorithms like HS256 work fine. But the moment you have multiple services validating tokens, asymmetric algorithms become essential. With RS256 or ES256, only the authentication service holds the private key, while all other services verify using the public key.

JWKS (JSON Web Key Set) endpoints provide a standardized way to distribute public keys and enable rotation:

import requests
from functools import lru_cache
from time import time
from typing import Optional
import jwt
from jwt import PyJWKClient

class JWKSKeyManager:
    def __init__(self, jwks_uri: str, cache_ttl: int = 3600):
        self.jwks_uri = jwks_uri
        self.cache_ttl = cache_ttl
        self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True)
        self._last_refresh = 0

    def get_signing_key(self, token: str) -> str:
        """
        Fetch the appropriate signing key for a token.
        Handles key rotation automatically via kid matching.
        """
        # Force refresh if cache is stale
        if time() - self._last_refresh > self.cache_ttl:
            self._jwks_client.fetch_data()
            self._last_refresh = time()

        signing_key = self._jwks_client.get_signing_key_from_jwt(token)
        return signing_key.key

    def validate_token(self, token: str, audience: str, issuer: str) -> dict:
        """Validate token using JWKS-managed keys."""
        key = self.get_signing_key(token)
        return jwt.decode(
            token,
            key,
            algorithms=["RS256", "ES256"],
            audience=audience,
            issuer=issuer,
        )

Key rotation becomes trivial with JWKS: publish a new key with a new kid (key ID), start signing new tokens with it, and remove the old key after existing tokens expire.

Token Storage and Transmission

Where you store tokens determines your attack surface. The two main options each have trade-offs:

HttpOnly cookies protect against XSS (JavaScript can’t access them) but require CSRF protection. localStorage is vulnerable to XSS but immune to CSRF.

For most applications, HttpOnly cookies with proper CSRF protection provide better security:

// Express.js secure cookie configuration
const cookieOptions = {
  httpOnly: true,      // Prevents JavaScript access
  secure: true,        // HTTPS only
  sameSite: 'strict',  // CSRF protection
  maxAge: 15 * 60 * 1000, // 15 minutes for access tokens
  path: '/',
  domain: '.yourdomain.com', // Scope to your domain
};

app.post('/auth/login', async (req, res) => {
  const { accessToken, refreshToken } = await authenticateUser(req.body);
  
  res.cookie('access_token', accessToken, cookieOptions);
  res.cookie('refresh_token', refreshToken, {
    ...cookieOptions,
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days for refresh tokens
    path: '/auth/refresh', // Limit refresh token scope
  });
  
  res.json({ success: true });
});

Notice the refresh token has a restricted path. This limits where the browser sends it, reducing exposure.

Refresh Token Patterns

Short-lived access tokens (15 minutes or less) combined with refresh token rotation provide a strong security posture. Each time a refresh token is used, issue a new one and invalidate the old:

from uuid import uuid4
from datetime import datetime, timedelta
import redis

class TokenRotationService:
    def __init__(self, redis_client: redis.Redis, jwt_secret: str):
        self.redis = redis_client
        self.secret = jwt_secret
        self.access_token_ttl = timedelta(minutes=15)
        self.refresh_token_ttl = timedelta(days=7)

    def issue_tokens(self, user_id: str) -> dict:
        """Issue new access and refresh token pair."""
        refresh_token_id = str(uuid4())
        
        access_token = self._create_access_token(user_id)
        refresh_token = self._create_refresh_token(user_id, refresh_token_id)
        
        # Store refresh token ID for validation and revocation
        self.redis.setex(
            f"refresh_token:{refresh_token_id}",
            self.refresh_token_ttl,
            user_id
        )
        
        return {
            "access_token": access_token,
            "refresh_token": refresh_token,
        }

    def rotate_refresh_token(self, old_refresh_token: str) -> dict:
        """
        Validate old refresh token, revoke it, issue new pair.
        Detects token reuse attacks.
        """
        claims = jwt.decode(
            old_refresh_token,
            self.secret,
            algorithms=["HS256"]
        )
        
        token_id = claims["jti"]
        user_id = claims["sub"]
        
        # Check if token exists and hasn't been used
        stored_user = self.redis.get(f"refresh_token:{token_id}")
        
        if stored_user is None:
            # Token reuse detected - revoke all user tokens
            self._revoke_all_user_tokens(user_id)
            raise ValueError("Refresh token reuse detected")
        
        # Revoke old token
        self.redis.delete(f"refresh_token:{token_id}")
        
        # Issue new pair
        return self.issue_tokens(user_id)

    def _revoke_all_user_tokens(self, user_id: str) -> None:
        """Nuclear option: revoke everything for a user."""
        # Increment user's token version, invalidating all existing tokens
        self.redis.incr(f"token_version:{user_id}")

The token reuse detection is critical: if someone uses an already-rotated refresh token, it indicates theft. The appropriate response is to invalidate everything and force re-authentication.

Security Checklist and Testing

Before deploying JWT authentication, verify these requirements:

  1. Algorithm explicitly whitelisted (never trust the header)
  2. Signature verified before reading any claims
  3. Expiration (exp) validated with reasonable clock skew
  4. Issuer (iss) and audience (aud) validated
  5. Secrets are strong (256+ bits of entropy) and securely stored
  6. Tokens transmitted only over HTTPS
  7. Appropriate storage mechanism (HttpOnly cookies preferred)
  8. Refresh token rotation implemented
  9. Token revocation strategy in place

Test your implementation against common attacks:

import pytest
import jwt

class TestJWTValidation:
    def test_rejects_none_algorithm(self, validator):
        """Ensure 'none' algorithm attack is blocked."""
        malicious_token = jwt.encode(
            {"sub": "attacker"},
            key="",
            algorithm="none"
        )
        with pytest.raises(ValueError, match="unauthorized algorithm"):
            validator.validate(malicious_token)

    def test_rejects_algorithm_confusion(self, validator, public_key):
        """Ensure RS256->HS256 confusion attack is blocked."""
        # Attacker tries to use public key as HMAC secret
        malicious_token = jwt.encode(
            {"sub": "attacker"},
            key=public_key,
            algorithm="HS256"
        )
        with pytest.raises(ValueError):
            validator.validate(malicious_token)

    def test_rejects_expired_token(self, validator, create_token):
        """Ensure expired tokens are rejected."""
        expired_token = create_token(exp=datetime.now() - timedelta(hours=1))
        with pytest.raises(ValueError, match="expired"):
            validator.validate(expired_token)

    def test_rejects_wrong_audience(self, validator, create_token):
        """Ensure tokens for other audiences are rejected."""
        wrong_audience_token = create_token(aud="other-service")
        with pytest.raises(ValueError, match="audience"):
            validator.validate(wrong_audience_token)

Tools like jwt_tool can automate many of these attacks during penetration testing. Run them against your staging environment before attackers run them against production.

JWT security isn’t complicated, but it requires attention to detail. Every validation step exists because someone, somewhere, skipped it and got breached. Don’t be that someone.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.