Skip to content
← Auth & Security · intermediate · 12 min · 03 / 08

JWT Deep Dive

Structure, signing algorithms, validation rules, and the common mistakes that make JWTs insecure.

JWTRS256HS256JWKStoken validation

Real-World Analogy

A notarized document: anyone can read it, no one can forge the notary’s seal without the private key, and the notary’s public record lets anyone verify the seal is genuine. JWTs work the same way — readable, tamper-evident, and verifiable by anyone with the public key.

Structure

A JWT is three base64url-encoded JSON objects joined by dots:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDAzNjAwfQ.signature

Header — algorithm and token type:

{ "alg": "RS256", "typ": "JWT" }

Payload — claims (user data + metadata):

{
  "sub": "user_123",        // subject — user ID
  "iss": "https://auth.yourapp.com", // issuer
  "aud": "api",             // audience
  "iat": 1700000000,        // issued at (Unix timestamp)
  "exp": 1700003600,        // expires at
  "role": "admin",          // custom claim
  "email": "user@example.com"
}

Signature — cryptographic proof the header+payload weren’t tampered with.

The payload is not encrypted — anyone can base64-decode it. Don’t put secrets in JWT claims.

Signing Algorithms

HS256 (HMAC-SHA256): Symmetric — same secret signs and verifies.

import jwt from 'jsonwebtoken';

const SECRET = process.env.JWT_SECRET!; // same secret everywhere

const token = jwt.sign({ sub: userId, role: 'user' }, SECRET, {
  algorithm: 'HS256',
  expiresIn: '1h',
  issuer: 'https://auth.yourapp.com',
  audience: 'api',
});

const payload = jwt.verify(token, SECRET, {
  algorithms: ['HS256'],
  issuer: 'https://auth.yourapp.com',
  audience: 'api',
});

Problem: every service that validates tokens needs the secret. If you have 10 services, the secret is in 10 places. One breach exposes the signing key.

RS256 (RSA-SHA256): Asymmetric — private key signs, public key verifies.

import { createPrivateKey, createPublicKey } from 'crypto';
import { SignJWT, jwtVerify, createRemoteJWKSet } from 'jose';

// Auth service only — has private key
const privateKey = createPrivateKey(process.env.JWT_PRIVATE_KEY!);

async function issueToken(userId: string, role: string): Promise<string> {
  return new SignJWT({ sub: userId, role })
    .setProtectedHeader({ alg: 'RS256' })
    .setIssuedAt()
    .setIssuer('https://auth.yourapp.com')
    .setAudience('api')
    .setExpirationTime('1h')
    .sign(privateKey);
}

// Any service — only needs public key (or JWKS URL)
const JWKS = createRemoteJWKSet(
  new URL('https://auth.yourapp.com/.well-known/jwks.json')
);

async function verifyToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://auth.yourapp.com',
    audience: 'api',
    algorithms: ['RS256'],
  });
  return payload;
}

ES256 (ECDSA P-256): Asymmetric like RS256, but shorter signatures and faster verification. Prefer this over RS256 for new systems.

// Generate a P-256 key pair
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
  namedCurve: 'P-256',
});

JWKS Endpoint

The JSON Web Key Set endpoint lets services fetch current public keys automatically. This enables key rotation without updating every service’s config:

import { exportJWK, generateKeyPair } from 'jose';

// Auth service: expose JWKS
let currentKeyPair = await generateKeyPair('ES256', { extractable: true });
let currentKeyId = 'key-2024-01';

app.get('/.well-known/jwks.json', async (req, res) => {
  const publicJwk = await exportJWK(currentKeyPair.publicKey);

  res.json({
    keys: [{
      ...publicJwk,
      kid: currentKeyId, // key ID — clients use this to pick the right key
      use: 'sig',        // intended use: signature verification
      alg: 'ES256',
    }],
  });
});

// Sign tokens with kid in header so verifiers know which key to use
async function sign(payload: Record<string, unknown>): Promise<string> {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'ES256', kid: currentKeyId })
    // ...
    .sign(currentKeyPair.privateKey);
}

Key rotation: Generate a new key pair, add it to JWKS alongside the old one (so tokens signed with the old key still validate), then after old tokens expire, remove the old key.

Validation Checklist

Verifying a JWT signature is not enough. Validate all of these:

async function validateToken(token: string): Promise<TokenPayload> {
  // 1. Verify signature against JWKS
  const { payload } = await jwtVerify(token, JWKS, {
    // 2. Check algorithm — NEVER allow 'none'
    algorithms: ['ES256', 'RS256'],

    // 3. Verify issuer matches expected auth server
    issuer: 'https://auth.yourapp.com',

    // 4. Verify audience matches this service
    audience: 'api',

    // 5. Expiry (exp) checked automatically by jwtVerify
    // 6. Not-before (nbf) checked automatically by jwtVerify
  });

  // 7. Check required claims exist
  if (!payload.sub) throw new Error('Missing sub claim');
  if (!payload.role) throw new Error('Missing role claim');

  // 8. Optionally check token ID (jti) against a revocation list
  if (payload.jti && await isRevoked(payload.jti as string)) {
    throw new Error('Token revoked');
  }

  return payload as TokenPayload;
}

The “alg: none” Attack

Early JWT libraries accepted "alg": "none" in the header, meaning no signature required. An attacker could forge any token by setting alg: none and providing no signature.

// Malicious token with alg:none
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiJ9.

Fix: always specify allowed algorithms explicitly and never include 'none'.

// WRONG — library might accept 'none'
jwt.verify(token, secret);

// RIGHT — explicit allowlist
jwt.verify(token, secret, { algorithms: ['HS256'] });
// or with jose: algorithms: ['ES256', 'RS256']

The RS256 → HS256 Confusion Attack

If a server uses RS256, it signs with a private key and verifies with the public key. An attacker who knows the public key (it’s public!) can craft a token signed with HS256 using the public key as the HMAC secret — then submit it to a server that accepts both algorithms.

Fix: never allow both symmetric and asymmetric algorithms for the same use case. Be explicit.

// WRONG — accepts both
algorithms: ['RS256', 'HS256']

// RIGHT — one or the other
algorithms: ['RS256']

Token Lifetime and Refresh

Short-lived access tokens + long-lived refresh tokens:

// Issue both on login
async function issueTokens(userId: string): Promise<{ accessToken: string; refreshToken: string }> {
  const accessToken = await signAccessToken(userId, '15m');  // short-lived
  const refreshToken = await signRefreshToken(userId, '30d'); // long-lived

  // Store refresh token hash in DB for revocation
  await db.refreshTokens.insert({
    tokenHash: hashToken(refreshToken),
    userId,
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
  });

  return { accessToken, refreshToken };
}

// Refresh endpoint
app.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.body;

  const tokenHash = hashToken(refreshToken);
  const stored = await db.refreshTokens.findByHash(tokenHash);

  if (!stored || stored.expiresAt < new Date()) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  // Rotate: invalidate old, issue new
  await db.refreshTokens.delete(stored.id);
  const tokens = await issueTokens(stored.userId);

  res.json(tokens);
});

Refresh token rotation (issuing a new refresh token on each use) detects theft: if an attacker uses a stolen refresh token, the legitimate user’s next refresh fails, alerting you to a compromise.

Where to Store Tokens in Browsers

StorageXSSCSRFNotes
localStorageVulnerableSafeAny script can read it
sessionStorageVulnerableSafeCleared on tab close
httpOnly cookieSafeVulnerableJS can’t read it; needs CSRF protection
Memory (JS variable)SafeSafeLost on page refresh

Recommendation: httpOnly, Secure, SameSite=Strict cookies for the refresh token. Access token in memory (JS variable), re-fetched from refresh endpoint on page load.

// Set refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
  httpOnly: true,   // not accessible to JS
  secure: true,     // HTTPS only
  sameSite: 'strict', // no cross-site requests
  maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
  path: '/auth/refresh', // only sent to refresh endpoint
});