Privacy Tools Guide

Secure JWT Implementation Best Practices

JSON Web Tokens are everywhere — and so are JWT vulnerabilities. The alg: none bypass, HMAC/RSA confusion, weak shared secrets, and missing expiry checks have each led to serious auth bypasses in production systems. This guide covers the full set of secure implementation requirements with working code.

JWT Structure Refresher

A JWT is three base64url-encoded parts joined by dots: header.payload.signature. The header declares the algorithm, the payload holds claims, and the signature proves integrity.

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9   <- header
.eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTc0MzEwMDAwMH0  <- payload
.signature_bytes_here                              <- signature

JWT does not encrypt by default. Anyone who has the token can decode the header and payload. If you need confidentiality, use JWE (JSON Web Encryption), not plain JWT.


Critical Vulnerabilities and Fixes

1. Algorithm Confusion (RS256 → HS256)

If your server accepts RS256 (asymmetric), an attacker can sometimes forge tokens by switching the algorithm to HS256 (symmetric) and signing with the public key as the HMAC secret — because the public key is often known.

The fix: always specify the expected algorithm explicitly on the server side:

# INSECURE — trusts the 'alg' header from the token
import jwt
payload = jwt.decode(token, public_key, algorithms=None)  # NEVER do this

# SECURE — hard-code the expected algorithm
payload = jwt.decode(token, public_key, algorithms=["RS256"])
// Node.js — jsonwebtoken library
// INSECURE
const payload = jwt.verify(token, publicKey);

// SECURE
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

2. The alg: none Attack

Some libraries historically accepted tokens with "alg": "none" and no signature. Fix: use a library that rejects none by default and never list it in allowed algorithms.

# Explicit rejection
ALLOWED_ALGORITHMS = ["RS256", "RS384", "RS512", "ES256", "ES384"]
# Never include "none", "HS256" if you use asymmetric keys
payload = jwt.decode(token, public_key, algorithms=ALLOWED_ALGORITHMS)

3. Weak HMAC Secrets

HS256 with a short or guessable secret can be brute-forced offline using tools like hashcat. The JWT signature is a MAC — it’s only as strong as the secret.

# Attack: hashcat can try millions of candidates per second
hashcat -a 0 -m 16500 captured_jwt.txt wordlist.txt

# Generate a cryptographically strong secret (32 bytes minimum for HS256)
openssl rand -base64 64   # 48 bytes → 64 base64 chars

For production, prefer RS256 with a 2048+ bit RSA key or ES256 with P-256. HMAC is fine for internal services where you control both ends and the secret is stored in a secrets manager.


Secure Token Generation

Python (PyJWT)

import jwt
import time
from pathlib import Path

# Load RS256 private key (generated with: openssl genrsa -out private.pem 2048)
PRIVATE_KEY = Path("/run/secrets/jwt_private_key.pem").read_text()

def create_token(user_id: str, roles: list[str]) -> str:
    now = int(time.time())
    payload = {
        "iss": "https://auth.example.com",     # issuer — who created the token
        "sub": str(user_id),                   # subject — the user
        "aud": "https://api.example.com",      # audience — intended recipient
        "iat": now,                            # issued at
        "nbf": now,                            # not valid before
        "exp": now + 3600,                     # expires in 1 hour
        "jti": secrets.token_hex(16),          # JWT ID — unique per token
        "roles": roles,
    }
    return jwt.encode(payload, PRIVATE_KEY, algorithm="RS256")

Node.js (jsonwebtoken)

const jwt = require('jsonwebtoken');
const fs  = require('fs');
const crypto = require('crypto');

const privateKey = fs.readFileSync('/run/secrets/jwt_private_key.pem');

function createToken(userId, roles) {
  return jwt.sign(
    {
      sub:   String(userId),
      aud:   'https://api.example.com',
      roles: roles,
      jti:   crypto.randomBytes(16).toString('hex'),
    },
    privateKey,
    {
      algorithm: 'RS256',
      issuer:    'https://auth.example.com',
      expiresIn: '1h',
      notBefore: 0,
    }
  );
}

Secure Token Validation

Always validate all of these claims on every request:

import jwt
from pathlib import Path

PUBLIC_KEY = Path("/run/secrets/jwt_public_key.pem").read_text()

def validate_token(token: str) -> dict:
    try:
        payload = jwt.decode(
            token,
            PUBLIC_KEY,
            algorithms=["RS256"],           # hard-coded — never dynamic
            audience="https://api.example.com",   # must match aud claim
            issuer="https://auth.example.com",    # must match iss claim
            options={
                "verify_exp": True,         # reject expired tokens
                "verify_nbf": True,         # reject tokens not yet valid
                "verify_iat": True,         # reject tokens with future iat
                "require": ["sub", "exp", "iat", "jti"],  # required claims
            },
            leeway=10,  # 10-second clock skew tolerance max
        )
        return payload
    except jwt.ExpiredSignatureError:
        raise AuthError("Token has expired")
    except jwt.InvalidAudienceError:
        raise AuthError("Token not intended for this service")
    except jwt.InvalidIssuerError:
        raise AuthError("Unknown token issuer")
    except jwt.DecodeError as e:
        raise AuthError(f"Invalid token: {e}")

Token Revocation

JWTs are stateless — they remain valid until expiry even if the user logs out or is banned. Fix this with a short expiry plus a blocklist for critical tokens:

import redis

r = redis.Redis(host="127.0.0.1", port=6379, db=0)

def revoke_token(jti: str, ttl_seconds: int):
    """Add JTI to blocklist with TTL matching token expiry."""
    r.setex(f"revoked_jti:{jti}", ttl_seconds, "1")

def is_revoked(jti: str) -> bool:
    return r.exists(f"revoked_jti:{jti}") > 0

# In validate_token, after decoding:
if is_revoked(payload["jti"]):
    raise AuthError("Token has been revoked")

Keep token TTL short (15–60 minutes for access tokens) and use refresh tokens with longer expiry for UX. Revoke refresh tokens on logout.


Key Rotation

# Generate a new RSA key pair
openssl genrsa -out new_private.pem 2048
openssl rsa -in new_private.pem -pubout -out new_public.pem

# Publish the new public key in your JWKS endpoint
# /well-known/jwks.json should expose ALL currently valid public keys
# so tokens signed with the old key continue to validate during rollover

Implement a JWKS endpoint so API consumers can auto-fetch valid public keys:

from fastapi import FastAPI
from jwt.algorithms import RSAAlgorithm
import json

app = FastAPI()

@app.get("/.well-known/jwks.json")
def jwks():
    public_key_pem = Path("/run/secrets/jwt_public_key.pem").read_text()
    jwk = json.loads(RSAAlgorithm.to_jwk(
        RSAAlgorithm.from_jwk(public_key_pem)
    ))
    jwk["kid"] = "2026-03-v1"   # key ID — increment on rotation
    jwk["use"] = "sig"
    return {"keys": [jwk]}

Common Mistakes Reference

Mistake Risk Fix
algorithms=None or algorithms=[] Algorithm confusion / none bypass Hard-code algorithms=["RS256"]
Missing exp validation Tokens never expire Always verify_exp: True
Missing aud validation Token accepted by wrong service Always verify aud
Short HS256 secret Offline brute-force Use RSA/EC or 256-bit secret
JWT in localStorage XSS token theft Use httpOnly secure cookies
Long TTL (days/weeks) Stolen token long window Access token ≤1h, refresh ≤14d
No JTI tracking Can’t revoke specific tokens Store JTI in Redis on issue


Built by theluckystrike — More at zovo.one