on } from 'express';
// Input Schema: Strictly defined fields only
const CreateUserSchema = z.object({
body: z.object({
username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/),
email: z.string().email(),
role: z.enum(['user', 'admin']).default('user'), // Prevent role escalation
}),
});
// Output Schema: Prevent excessive data exposure
const UserResponseSchema = z.object({
id: z.string().uuid(),
username: z.string(),
email: z.string().email(),
// Password, salt, and internal flags are excluded
});
export const validate = (schema: z.ZodTypeAny) => (
req: Request,
res: Response,
next: NextFunction
) => {
try {
schema.parse({ body: req.body, params: req.params, query: req.query });
next();
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Validation failed', details: err.errors });
} else {
next(err);
}
}
};
#### 2. Context-Aware Authorization Middleware
Implement BOLA protection by verifying resource ownership. This middleware should be applied to endpoints accessing specific resources.
```typescript
import { Request, Response, NextFunction } from 'express';
import { db } from './db'; // Hypothetical database client
interface AuthRequest extends Request {
user: {
id: string;
roles: string[];
};
}
// Middleware to check if user owns the resource
export const authorizeResource = async (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
const resourceId = req.params.id;
const userId = req.user.id;
try {
// Fetch resource to verify ownership
const resource = await db.getResource(resourceId);
if (!resource) {
return res.status(404).json({ error: 'Resource not found' });
}
// BOLA Check: Ensure user owns the resource or has admin role
const isOwner = resource.ownerId === userId;
const isAdmin = req.user.roles.includes('admin');
if (!isOwner && !isAdmin) {
return res.status(403).json({ error: 'Forbidden: You do not own this resource' });
}
next();
} catch (error) {
next(error);
}
};
3. Rate Limiting and Throttling
Implement rate limiting based on user identity to prevent abuse. Use Redis for distributed state management.
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
const redisClient = new Redis(process.env.REDIS_URL);
// Standard Rate Limiter for API endpoints
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req: Request) => {
// Use user ID if authenticated, otherwise IP
return (req as AuthRequest).user?.id || req.ip;
},
store: new RedisStore({
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
}),
});
// Stricter Limiter for Authentication Endpoints
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Limit each IP to 5 login requests per 15 minutes
message: { error: 'Too many login attempts, please try again later' },
});
4. Secure Error Handling
Never expose stack traces or internal details. Implement a global error handler.
export const errorHandler = (
err: Error,
req: Request,
res: Response,
next: NextFunction
) => {
// Log error internally for debugging
console.error(`[API Error] ${err.message}`, err.stack);
// Determine status code
const statusCode = res.statusCode !== 200 ? res.statusCode : 500;
// Return sanitized response
res.status(statusCode).json({
error: statusCode === 500 ? 'Internal Server Error' : err.message,
// Do not include stack trace or internal data in production
});
};
Application Setup
Combine middleware in the correct order. Security middleware must run before business logic.
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
const app = express();
// Security Headers
app.use(helmet());
// CORS Configuration: Restrict origins
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || [],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
app.use(express.json({ limit: '10kb' })); // Limit payload size
// Rate Limiting
app.use('/api/', apiLimiter);
app.post('/auth/login', authLimiter);
// Routes
app.post('/users', validate(CreateUserSchema), createUserHandler);
app.get('/users/:id', authenticate, authorizeResource, getUserHandler);
// Error Handler
app.use(errorHandler);
Pitfall Guide
1. Broken Object Level Authorization (BOLA/IDOR)
Mistake: Relying on client-side controls or UUIDs to prevent unauthorized access. Attackers modify resource IDs in requests to access data belonging to other users.
Explanation: UUIDs are not secure against enumeration or guessing if not properly validated. The backend must verify that the authenticated user has permission to access the specific resource ID requested.
Best Practice: Implement the authorizeResource pattern shown in the Core Solution. Every endpoint accessing a resource must check ownership or role-based access control (RBAC) against the database.
2. Mass Assignment
Mistake: Binding request body directly to database models without filtering.
Explanation: If a schema allows { username, email, role } and the database model has a role field, an attacker can inject "role": "admin" in the request body to escalate privileges.
Best Practice: Use explicit allow-lists in schemas (like Zod's pick or strict object definitions). Never use req.body directly in ORM update methods; map allowed fields explicitly.
3. Broken Object Property Level Authorization (BOPLA)
Mistake: Returning sensitive fields in API responses that the user should not see.
Explanation: Even if a user can access a resource, they may not have permission to view all properties. For example, returning a user's internal creditScore or salary to a regular user.
Best Practice: Define output schemas that exclude sensitive fields. Use response serialization to filter data based on the user's role. The UserResponseSchema example demonstrates this by omitting internal flags.
4. Unrestricted Resource Consumption
Mistake: Failing to limit payload size, query complexity, or request rates.
Explanation: Attackers can send massive payloads or complex nested queries to exhaust CPU, memory, or database connections, causing Denial of Service (DoS).
Best Practice: Enforce payload limits (express.json({ limit: '10kb' })). Implement pagination and query depth limits for GraphQL. Use rate limiting per user and per endpoint.
5. Security Misconfiguration (CORS and Headers)
Mistake: Using wildcard CORS (*) or missing security headers.
Explanation: Wildcard CORS allows any origin to make cross-origin requests, enabling Cross-Site Request Forgery (CSRF) and data theft from malicious sites. Missing headers like X-Content-Type-Options can lead to MIME sniffing attacks.
Best Practice: Configure CORS with explicit allowed origins. Use helmet to set secure headers automatically. Disable unnecessary HTTP methods.
6. Insecure Logging and Monitoring
Mistake: Logging sensitive data like passwords, tokens, or PII.
Explanation: Logs are often stored in plain text and accessible to many team members. Logging secrets or PII creates a compliance violation and a secondary attack vector if logs are leaked.
Best Practice: Implement log sanitization middleware to redact sensitive fields. Use structured logging with levels (Info, Warn, Error). Never log full request bodies containing secrets; log only metadata and error summaries.
7. Trusting API Keys for Authentication
Mistake: Using API keys as the sole mechanism for user authentication in client-side applications.
Explanation: API keys embedded in client apps (mobile, browser) can be extracted. If used for authentication, attackers can impersonate the application or users.
Best Practice: API keys should identify the application, not the user. Use OAuth2 or JWT for user authentication. API keys can be used for service-to-service communication where the key is stored securely on the server.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Public B2C API | OAuth2 + JWT + Rate Limiting | Supports user sessions, scopes, and revocation; scales well. | Moderate (Identity provider costs). |
| Internal Microservices | mTLS + Service Mesh | Zero-trust network, automatic encryption, and identity verification between services. | Low (Infrastructure overhead). |
| Partner/B2B Integration | API Keys + HMAC Signing | Simple key management; HMAC ensures request integrity and non-repudiation. | Low (Key rotation management). |
| High-Value Transactions | Step-Up Auth + Anomaly Detection | Requires additional verification for sensitive actions; detects behavioral anomalies. | High (User friction, ML costs). |
| Legacy API Migration | WAF + API Gateway Shield | Quick win to protect legacy code without refactoring; gateway handles auth/validation. | Low (Gateway licensing). |
Configuration Template
Copy this security-preset.ts to bootstrap API security in new projects.
// security-preset.ts
import { z } from 'zod';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
import helmet from 'helmet';
import cors from 'cors';
import { Application, Request, Response, NextFunction } from 'express';
const redis = new Redis(process.env.REDIS_URL!);
export const applySecurityPreset = (app: Application) => {
// 1. Headers & CORS
app.use(helmet());
app.use(cors({
origin: process.env.CORS_ORIGIN?.split(',') || [],
credentials: true,
}));
// 2. Rate Limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),
keyGenerator: (req) => (req as any).user?.id || req.ip,
});
app.use('/api', limiter);
// 3. Payload Limits
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
// 4. Global Error Handler (Sanitized)
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(`[Security Error] ${err.message}`);
const status = res.statusCode !== 200 ? res.statusCode : 500;
res.status(status).json({ error: status === 500 ? 'Internal Error' : err.message });
});
};
// Utility: Strict Validator
export const validateRequest = <T extends z.ZodTypeAny>(schema: T) => {
return (req: Request, res: Response, next: NextFunction) => {
try {
req.validated = schema.parse({
body: req.body,
params: req.params,
query: req.query,
});
next();
} catch (err) {
res.status(400).json({ error: 'Validation Error', details: (err as z.ZodError).errors });
}
};
};
// Utility: BOLA Guard
export const checkBOLA = (resourceModel: any) => {
return async (req: Request, res: Response, next: NextFunction) => {
const id = req.params.id;
const user = (req as any).user;
const resource = await resourceModel.findById(id);
if (!resource) return res.status(404).json({ error: 'Not Found' });
if (resource.ownerId !== user.id && !user.roles.includes('admin')) {
return res.status(403).json({ error: 'Forbidden' });
}
(req as any).resource = resource;
next();
};
};
Quick Start Guide
-
Initialize Project:
npm init -y
npm i express zod helmet cors express-rate-limit ioredis rate-limit-redis
npm i -D @types/express @types/node typescript ts-node
-
Create Security Config:
Create security.ts and paste the Configuration Template code. Ensure process.env variables for Redis and CORS are set.
-
Define Schemas:
In your route file, define Zod schemas for inputs. Use validateRequest middleware on routes.
const schema = z.object({ body: z.object({ name: z.string() }) });
app.post('/items', validateRequest(schema), handler);
-
Apply Middleware:
Import and apply applySecurityPreset in your main app entry point.
import { applySecurityPreset } from './security';
const app = express();
applySecurityPreset(app);
-
Test and Validate:
Run the server. Use curl or Postman to test:
- Send invalid JSON to verify 400 response.
- Send requests without auth to verify 401.
- Access resource with wrong ID to verify BOLA check.
- Flood requests to verify rate limiting.
This bundle provides a production-ready foundation that mitigates the top API risks while maintaining developer velocity through reusable middleware and strict typing.