atures, expiration, issuer, and audience on every request. Use short-lived access tokens with refresh token rotation.
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';
const JWT_PUBLIC_KEY = process.env.JWT_PUBLIC_KEY;
const ISSUER = process.env.JWT_ISSUER;
const AUDIENCE = process.env.JWT_AUDIENCE;
export const validateJwt = (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or malformed authorization header' });
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, JWT_PUBLIC_KEY!, {
algorithms: ['RS256'],
issuer: ISSUER,
audience: AUDIENCE,
clockTolerance: 30,
}) as { sub: string; scope: string[]; exp: number };
req.user = { id: payload.sub, scopes: payload.scope };
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
};
Runtime validation prevents injection, mass assignment, and data type escalation. Use Zod for compile-time and runtime schema enforcement.
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
const CreateUserSchema = z.object({
body: z.object({
email: z.string().email(),
role: z.enum(['viewer', 'editor', 'admin']).default('viewer'),
metadata: z.record(z.string(), z.string()).optional(),
}),
params: z.object({
orgId: z.string().uuid(),
}),
});
export const validateInput = (schema: z.ZodSchema) => {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse({ body: req.body, params: req.params, query: req.query });
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors
});
}
req.validated = result.data;
next();
};
};
Step 3: Rate Limiting & Throttling
Rate limiting mitigates credential stuffing, scraping, and denial-of-service attempts. Implement a sliding window token bucket backed by Redis for distributed consistency.
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
export const apiRateLimiter = rateLimit({
store: new RedisStore({ client: redisClient, prefix: 'rl:api:' }),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per window
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please retry after 15 minutes' },
});
Step 4: Authorization Enforcement (BOLA Prevention)
Authentication proves identity; authorization proves permission. Enforce object-level access control by verifying that the requesting user owns or has explicit scope for the requested resource.
import { Request, Response, NextFunction } from 'express';
export const requireOwnership = async (req: Request, res: Response, next: NextFunction) => {
const userId = req.user?.id;
const resourceId = req.params.id;
if (!userId || !resourceId) {
return res.status(400).json({ error: 'Missing user or resource identifier' });
}
// Database check: verify ownership or explicit grant
const hasAccess = await checkResourceAccess(userId, resourceId);
if (!hasAccess) {
return res.status(403).json({ error: 'Access denied: insufficient permissions' });
}
next();
};
async function checkResourceAccess(userId: string, resourceId: string): Promise<boolean> {
// Implementation depends on your data layer.
// Must query permissions table, not assume ownership from path parameters.
return false; // Placeholder
}
Strip unnecessary headers, enforce HTTPS, prevent clickjacking, and restrict resource loading policies.
import helmet from 'helmet';
import cors from 'cors';
export const secureHeaders = helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
},
},
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: true,
crossOriginResourcePolicy: { policy: 'same-origin' },
});
export const strictCors = cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || [],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400,
});
Architecture Decisions & Rationale
The pipeline follows a strict order: transport security β identity validation β input sanitization β rate limiting β authorization β business logic. This sequence ensures that malformed requests, unauthenticated traffic, and abusive patterns are rejected before consuming database or compute resources. Statelessness is preserved by validating tokens against public keys rather than session stores. Authorization is decoupled from authentication to support fine-grained access control across microservices. All middleware is idempotent and fails closed, meaning any validation error returns a consistent 400/401/403 response without leaking internal state.
Pitfall Guide
-
Assuming Client-Side Validation Protects the API
Client-side checks improve UX but provide zero security. Attackers bypass browsers using curl, Postman, or custom scripts. Always enforce schema validation, type coercion, and length limits on the server. Production systems that skip server-side validation routinely suffer from SQL injection, mass assignment, and buffer overflow vulnerabilities.
-
Ignoring Broken Object Level Authorization (BOLA)
BOLA occurs when endpoints rely on client-supplied IDs without verifying ownership. Changing /api/users/1001 to /api/users/1002 should not expose another user's data. Implement explicit permission checks against a central policy engine or database query. Never trust path parameters as authorization proof.
-
Overly Permissive CORS Configurations
Setting Access-Control-Allow-Origin: * or echoing the Origin header dynamically disables same-origin protections. Attackers exploit this to exfiltrate data from authenticated sessions via malicious sites. Restrict origins to known domains, validate against an allowlist, and never use wildcards in production.
-
Treating Rate Limiting as an Authentication Control
Rate limiting slows down brute-force attempts but does not validate credentials. Attackers will distribute requests across IPs, use proxy pools, or target low-volume endpoints. Combine rate limiting with account lockout policies, CAPTCHA challenges, and behavioral analytics. Rate limits should be applied per user, per IP, and per endpoint.
-
Hardcoding API Keys or Using Long-Lived Tokens
Static keys embedded in code or configuration files inevitably leak through version control, logs, or client bundles. Rotate keys automatically, use short-lived JWTs with refresh token rotation, and store secrets in a dedicated vault (HashiCorp Vault, AWS Secrets Manager). Implement key revocation workflows that propagate within seconds.
-
Leaving Deprecated Endpoints Unmonitored
Versioned APIs (/v1/, /v2/) often retain legacy endpoints that lack modern security controls. Deprecated routes become attack surfaces for BOLA, excessive data exposure, and outdated cryptographic algorithms. Sunset endpoints with explicit deprecation headers, route traffic to a gateway that enforces current policies, and decommission routes after the compliance window.
-
Skipping Request/Response Logging for Compliance
Security without observability is blind. Failing to log authentication attempts, authorization denials, and validation failures prevents incident reconstruction. Log metadata (not payloads), correlate with trace IDs, and ship to an immutable audit store. Ensure logs comply with GDPR/CCPA by masking PII and implementing retention policies.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal microservice mesh | mTLS + short-lived JWTs + service mesh RBAC | Eliminates network-level trust, enforces least privilege without perimeter overhead | Low infrastructure cost, moderate engineering effort |
| Public B2C API | OAuth2 PKCE + rate limiting + WAF + behavioral analytics | Protects against credential stuffing, scraping, and DDoS while maintaining UX | Moderate infrastructure cost, high scalability requirement |
| Partner B2B API | Mutual TLS + API keys with scoped permissions + webhook verification | Ensures cryptographic identity, limits blast radius, enables audit trails | High initial setup cost, low ongoing maintenance |
Configuration Template
// security-pipeline.ts
import { Application } from 'express';
import { validateJwt } from './middleware/auth';
import { validateInput } from './middleware/validation';
import { apiRateLimiter } from './middleware/rate-limit';
import { requireOwnership } from './middleware/authorization';
import { secureHeaders, strictCors } from './middleware/transport';
import { CreateUserSchema } from './schemas/user';
export function applySecurityPipeline(app: Application) {
// 1. Transport & Headers
app.use(strictCors);
app.use(secureHeaders);
// 2. Global Rate Limiting
app.use('/api/', apiRateLimiter);
// 3. Authentication
app.use('/api/', validateJwt);
// 4. Route-Specific Security
app.post('/api/orgs/:orgId/users',
validateInput(CreateUserSchema),
requireOwnership,
async (req, res) => {
// Business logic executes only after all security gates pass
res.status(201).json({ message: 'User created' });
}
);
// 5. Fallback: Reject unmatched routes
app.use('*', (req, res) => {
res.status(404).json({ error: 'Endpoint not found or deprecated' });
});
}
Quick Start Guide
- Install dependencies:
npm i express zod jsonwebtoken helmet cors express-rate-limit rate-limit-redis redis
- Configure environment variables:
JWT_PUBLIC_KEY, JWT_ISSUER, JWT_AUDIENCE, REDIS_URL, ALLOWED_ORIGINS
- Apply the security pipeline to your Express app using
applySecurityPipeline(app) before registering business routes
- Run a security scan:
npx @apisec/scan --url http://localhost:3000 --token $TEST_TOKEN to validate BOLA, rate limiting, and header enforcement
- Deploy with health checks that verify middleware initialization and Redis connectivity before accepting traffic