usiness logic from leaking into authentication flows.
2. Explicit Claim Validation: Relying solely on jwt.verify() is insufficient. Production systems must validate exp, iat, iss, and aud claims to prevent token reuse across environments or services.
3. Asymmetric Secret Management: Secrets are never hardcoded. They are injected via environment variables and validated at startup to fail fast in misconfigured deployments.
4. Typed Payload Contracts: TypeScript interfaces enforce claim structure, preventing accidental injection of unvalidated data into tokens.
Implementation
import express, { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
// βββ Type Definitions βββ
interface UserRecord {
id: string;
email: string;
passwordHash: string;
role: 'admin' | 'user';
}
interface TokenPayload {
sub: string;
email: string;
role: string;
iat: number;
exp: number;
}
// βββ Configuration βββ
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
if (!ACCESS_SECRET) throw new Error('Missing JWT_ACCESS_SECRET');
const TOKEN_EXPIRY = '8h';
const SALT_ROUNDS = 12;
// βββ Simulated Data Store βββ
const userRepository: Map<string, UserRecord> = new Map();
// βββ Token Service βββ
class IdentityService {
static generateAccess(user: UserRecord): string {
const payload: Omit<TokenPayload, 'iat' | 'exp'> = {
sub: user.id,
email: user.email,
role: user.role,
};
return jwt.sign(payload, ACCESS_SECRET, {
algorithm: 'HS256',
expiresIn: TOKEN_EXPIRY,
issuer: 'auth-system',
});
}
static verifyAccess(token: string): TokenPayload {
return jwt.verify(token, ACCESS_SECRET, {
algorithms: ['HS256'],
issuer: 'auth-system',
}) as TokenPayload;
}
}
// βββ Middleware Factory βββ
function requireAuth(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or malformed authorization header' });
return;
}
const token = authHeader.split(' ')[1];
try {
const claims = IdentityService.verifyAccess(token);
(req as any).user = claims;
next();
} catch (err) {
res.status(403).json({ error: 'Invalid or expired token' });
}
}
// βββ Route Handlers βββ
const app = express();
app.use(express.json());
app.post('/auth/register', async (req: Request, res: Response) => {
const { email, password, role } = req.body;
if (!email || !password) {
res.status(400).json({ error: 'Email and password are required' });
return;
}
if (userRepository.has(email)) {
res.status(409).json({ error: 'Account already exists' });
return;
}
const hash = await bcrypt.hash(password, SALT_ROUNDS);
const newUser: UserRecord = {
id: crypto.randomUUID(),
email,
passwordHash: hash,
role: role || 'user',
};
userRepository.set(email, newUser);
res.status(201).json({ id: newUser.id, email: newUser.email });
});
app.post('/auth/login', async (req: Request, res: Response) => {
const { email, password } = req.body;
const account = userRepository.get(email);
if (!account || !(await bcrypt.compare(password, account.passwordHash))) {
res.status(401).json({ error: 'Invalid credentials' });
return;
}
const accessToken = IdentityService.generateAccess(account);
res.json({ accessToken });
});
app.get('/api/resource', requireAuth, (req: Request, res: Response) => {
const user = (req as any).user as TokenPayload;
res.json({ message: 'Access granted', subject: user.sub, role: user.role });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Identity service listening on :${PORT}`));
Why This Structure Works
IdentityService encapsulation: Centralizes cryptographic operations. If you later migrate to RS256 or add refresh token logic, changes are isolated to one module.
- Explicit algorithm enforcement: Passing
algorithms: ['HS256'] during verification prevents algorithm confusion attacks where an attacker manipulates the alg header to bypass signature checks.
- Issuer validation: The
issuer claim ensures tokens generated by other services or environments cannot be replayed against this endpoint.
- Middleware factory pattern:
requireAuth returns a standard Express middleware signature, making it reusable across any route without duplicating extraction or error-handling logic.
Pitfall Guide
1. Treating Payload as Encrypted Storage
Explanation: JWT payloads are Base64-encoded, not encrypted. Anyone with the token can decode and read the claims.
Fix: Never store passwords, API keys, or PII in the payload. Use minimal identifiers (sub, email) and fetch sensitive data from a secure backend if needed.
2. Algorithm Confusion Vulnerability
Explanation: If the verification library accepts any algorithm specified in the header, an attacker can change alg to none or switch from RS256 to HS256 to forge tokens.
Fix: Always explicitly whitelist accepted algorithms in jwt.verify(). Never trust the alg header from untrusted clients.
3. Omitting Expiration Constraints
Explanation: Tokens without exp claims remain valid indefinitely. If leaked, they provide permanent access until the secret rotates.
Fix: Enforce expiresIn during signing. Use short-lived access tokens (15m-8h) paired with refresh tokens for long sessions.
4. Hardcoded or Weak Secrets
Explanation: Development secrets like my-secret-key are trivially brute-forced. Weak secrets compromise the entire signature verification model.
Fix: Generate cryptographically secure secrets (minimum 256 bits). Store them in environment variables or secret managers. Validate presence at startup.
5. Ignoring Token Revocation
Explanation: Stateless tokens cannot be invalidated server-side until expiration. Logging out or password changes don't automatically revoke existing tokens.
Fix: Implement a token blacklist (Redis/in-memory), shorten TTLs, or bind tokens to a version or passwordChangedAt claim that you validate against the user record.
6. Client Storage Misconfiguration
Explanation: Storing tokens in localStorage exposes them to XSS attacks. Storing in standard cookies exposes them to CSRF.
Fix: Use httpOnly, Secure, SameSite=Strict cookies for web apps. For SPAs/mobile, use in-memory storage with secure refresh flows. Never persist tokens in accessible storage.
7. Payload Bloat
Explanation: Adding excessive claims increases token size. Since tokens travel in HTTP headers on every request, oversized payloads degrade network performance and may exceed header limits.
Fix: Keep payloads under 1KB. Store only identity anchors. Fetch contextual data via dedicated endpoints after authentication succeeds.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single monolith, low concurrency | Session-based auth | Simpler revocation, no client storage complexity | Low infrastructure cost, higher DB load |
| Microservices, high scale | JWT with short TTL + Redis blacklist | Stateless validation, horizontal scaling, fast revocation | Moderate Redis cost, reduced DB I/O |
| Public API, third-party clients | OAuth2 / OIDC with JWT | Standardized delegation, scoped access, ecosystem compatibility | Higher initial setup, lower long-term maintenance |
| Mobile/SPA with refresh flows | JWT access + httpOnly refresh cookie | Balances XSS/CSRF tradeoffs, enables silent token renewal | Minimal overhead, improved UX |
Configuration Template
// config/security.ts
import crypto from 'crypto';
export const JWT_CONFIG = {
accessSecret: process.env.JWT_ACCESS_SECRET || crypto.randomBytes(32).toString('hex'),
refreshSecret: process.env.JWT_REFRESH_SECRET || crypto.randomBytes(32).toString('hex'),
accessTTL: '4h',
refreshTTL: '7d',
algorithm: 'HS256' as const,
issuer: 'platform-api',
audience: 'client-app',
};
export const COOKIE_CONFIG = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict' as const,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/auth/refresh',
};
// middleware/authGuard.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { JWT_CONFIG } from '../config/security';
export function authGuard(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
try {
const payload = jwt.verify(token, JWT_CONFIG.accessSecret, {
algorithms: [JWT_CONFIG.algorithm],
issuer: JWT_CONFIG.issuer,
audience: JWT_CONFIG.audience,
});
(req as any).claims = payload;
next();
} catch {
res.status(403).json({ error: 'Token invalid or expired' });
}
}
Quick Start Guide
- Initialize project:
npm init -y && npm i express jsonwebtoken bcrypt cors helmet
- Set environment variables: Create
.env with JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, and NODE_ENV=production
- Copy configuration template: Place
config/security.ts and middleware/authGuard.ts in your project structure
- Wire routes: Import
authGuard into protected endpoints and use jwt.sign() with explicit options during login
- Test verification: Send a request with
Authorization: Bearer <token> and confirm payload extraction and expiration enforcement
This architecture delivers stateless, cryptographically verified identity management without coupling authentication to database state. By enforcing strict claim validation, managing token lifecycle explicitly, and aligning client storage with threat models, you gain predictable scaling characteristics and a defensible security posture.