crypto utility. Note the explicit rejection of none, constant-time comparison, and strict claim validation.
import { createHmac, timingSafeEqual } from 'crypto';
// Strict payload interface to prevent arbitrary claim injection
interface SecureJwtPayload {
sub: string;
scope: string[];
jti: string; // Unique ID for revocation
iat: number;
exp: number;
ver?: number; // User version for bulk revocation
}
interface JwtConfig {
secret: Buffer; // Must be 256-bit minimum
algorithm: 'HS256' | 'RS256'; // Pinned algorithm
maxClockSkew: number; // Seconds
tokenExpiry: number; // Seconds
}
class JwtSecurityService {
private config: JwtConfig;
constructor(config: JwtConfig) {
this.validateConfig(config);
this.config = config;
}
private validateConfig(config: JwtConfig): void {
if (config.secret.byteLength < 32) {
throw new Error('Secret must be at least 256 bits (32 bytes)');
}
if (config.maxClockSkew > 30) {
console.warn('Clock skew exceeds 30 seconds; consider reducing to minimize replay windows.');
}
}
/**
* Verifies a JWT string. Throws on any validation failure.
* Returns the payload only after full verification.
*/
public verify(token: string): SecureJwtPayload {
const [headerB64, payloadB64, signatureB64] = token.split('.');
if (!headerB64 || !payloadB64 || !signatureB64) {
throw new Error('Malformed token structure');
}
// 1. Decode and validate header
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());
// CRITICAL: Reject 'none' and pin algorithm
if (header.alg === 'none' || header.alg === 'None') {
throw new Error('Algorithm "none" is not permitted');
}
if (header.alg !== this.config.algorithm) {
throw new Error(`Algorithm mismatch: expected ${this.config.algorithm}, got ${header.alg}`);
}
// 2. Verify Signature using constant-time comparison
const signingInput = `${headerB64}.${payloadB64}`;
const expectedSignature = this.computeSignature(signingInput);
const providedSignature = Buffer.from(signatureB64, 'base64url');
if (expectedSignature.length !== providedSignature.length) {
throw new Error('Invalid signature length');
}
if (!timingSafeEqual(expectedSignature, providedSignature)) {
throw new Error('Signature verification failed');
}
// 3. Validate Claims
const payload: SecureJwtPayload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now - this.config.maxClockSkew) {
throw new Error('Token expired');
}
if (payload.iat > now + this.config.maxClockSkew) {
throw new Error('Token issued in the future');
}
// 4. Optional: Check revocation (jti or version)
// In production, this would query a Redis cache or DB
// if (this.isRevoked(payload.jti)) throw new Error('Token revoked');
return payload;
}
private computeSignature(data: string): Buffer {
return createHmac('sha256', this.config.secret).update(data).digest();
}
}
Rationale
timingSafeEqual: Standard string comparison is vulnerable to timing attacks. Using a constant-time comparison ensures the verification duration does not leak information about the signature.
- Header Parsing Before Verification: The header is decoded to check the algorithm, but the payload is only trusted after the signature is verified. This prevents attackers from injecting malicious claims that might be processed before signature validation in poorly written code.
ver Claim: Including a user version allows instant revocation of all tokens for a user (e.g., on password change) by incrementing the version in the database. The middleware can check this against a cached value, avoiding a full token blacklist.
Pitfall Guide
Production JWT implementations frequently fail due to subtle configuration errors. The following pitfalls represent the most common vectors for exploitation.
1. Algorithm Confusion (RS256 β HS256)
Explanation: If the server expects RS256 (asymmetric), an attacker can change the header to HS256 (symmetric) and sign the token using the server's public key as the HMAC secret. Naive libraries will verify the signature successfully because the public key is known.
Fix: Always pin the expected algorithm in the server configuration. Never allow the client to dictate the algorithm via the token header.
2. Accepting alg: none
Explanation: Some libraries interpret alg: none as a valid algorithm that requires no signature. An attacker can forge any payload by setting this header and omitting the signature.
Fix: Explicitly reject any token where the algorithm is none, None, or case variants. Ensure your library version has patched known none acceptance bugs.
3. Insufficient Secret Entropy
Explanation: HMAC secrets derived from passwords or short strings are vulnerable to offline brute-force attacks. An attacker with a valid token can crack weak secrets in seconds using GPU clusters.
Fix: Generate secrets using a cryptographically secure random number generator. Enforce a minimum of 256 bits (32 bytes) of entropy. Store secrets in a secure vault, not in code or environment variables accessible to all services.
4. Revocation Blindness
Explanation: Because JWTs are stateless, there is no built-in mechanism to revoke a token before expiration. If a token is stolen, it remains valid until exp.
Fix: Implement a revocation strategy. Options include:
- JTI Blacklist: Store revoked
jti values in a fast cache (e.g., Redis) with a TTL matching the token expiry.
- User Versioning: Include a
ver claim. Check the claim against the current user version in the database.
- Short Expiry: Minimize the window of exposure by using short-lived access tokens (e.g., 15 minutes).
5. Client-Side Storage Vulnerabilities
Explanation: Storing JWTs in localStorage or sessionStorage exposes them to Cross-Site Scripting (XSS) attacks. Any malicious script running on the page can exfiltrate the token.
Fix: For browser-based clients, store access tokens in httpOnly, Secure, and SameSite=Strict cookies. This prevents JavaScript access and mitigates XSS token theft. Use refresh tokens in separate cookies with rotation.
6. Excessive Clock Skew
Explanation: Libraries often allow a "clock skew" tolerance to handle time differences between servers. A large skew (e.g., 5 minutes or more) extends the validity window of expired tokens and allows replay attacks.
Fix: Configure clock skew to the minimum necessary, typically 30 seconds or less. Ensure all servers are synchronized via NTP to reduce the need for skew.
7. Payload Pollution
Explanation: Developers often embed sensitive data or excessive claims in the JWT payload. Since the payload is Base64URL encoded (not encrypted), anyone can decode it.
Fix: Treat the JWT payload as public data. Never include passwords, PII, or internal identifiers that could aid an attacker. Minimize claims to only what is required for authorization decisions.
Production Bundle
Action Checklist
Decision Matrix
Select the JWT strategy based on your client type and security requirements.
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single Page App (SPA) | HttpOnly Cookies + CSRF Protection | Prevents XSS token theft; requires CSRF mitigation. | Medium (Cookie management) |
| Mobile/Native App | Secure Storage + Refresh Rotation | No cookie support; secure storage APIs provide isolation. | Low |
| Microservices | RS256 + JWKS Endpoint | Enables key rotation without shared secrets; services verify independently. | High (Key management infra) |
| High-Security API | Short Expiry + JTI Blacklist | Minimizes blast radius; allows instant revocation via Redis. | Medium (Cache overhead) |
Configuration Template
Use this TypeScript configuration template to enforce security defaults in your application.
// jwt.security.config.ts
export interface JwtSecurityConfig {
// Algorithm must be explicitly defined; never derived from token
algorithm: 'HS256' | 'RS256';
// Secret key source. For HS256, must be 32+ bytes.
// For RS256, provide the public key PEM.
keySource: () => Promise<Buffer | string>;
// Strict clock skew limit in seconds
maxClockSkew: number;
// Access token validity in seconds (recommend 900 for 15 mins)
accessTokenExpiry: number;
// Refresh token validity in seconds
refreshTokenExpiry: number;
// Enable JTI verification for revocation
enforceJti: boolean;
// Custom claim validation function
validateClaims?: (payload: Record<string, unknown>) => boolean;
}
export const defaultSecureConfig: JwtSecurityConfig = {
algorithm: 'HS256',
keySource: async () => {
const key = process.env.JWT_SECRET_KEY;
if (!key || Buffer.byteLength(key, 'utf8') < 32) {
throw new Error('JWT secret key is missing or insufficient entropy');
}
return Buffer.from(key, 'utf8');
},
maxClockSkew: 30,
accessTokenExpiry: 900,
refreshTokenExpiry: 604800,
enforceJti: true,
validateClaims: (payload) => {
// Example: Ensure required scope exists
return Array.isArray(payload.scope) && payload.scope.length > 0;
}
};
Quick Start Guide
- Generate Secure Key: Run
openssl rand -hex 32 to generate a 256-bit secret. Store this in your secrets manager.
- Configure Middleware: Initialize your JWT service with the secure configuration template. Pin the algorithm and set expiry to 15 minutes.
- Implement Refresh Flow: Create a
/auth/refresh endpoint that validates the refresh token, issues a new access token, and rotates the refresh token.
- Run Negative Tests: Execute a test suite that includes
alg: none, expired tokens, and tampered payloads. Verify all return 401.
- Deploy with Monitoring: Enable logging for verification failures to detect brute-force attempts or configuration errors in production.