Build a Secure Express API with Helmet, Zod, and Rate Limiting
Scaffold an Express 5 REST API with security headers, schema validation, and request throttling built in.
What you'll build
A small Express REST API with one resource (/users) that ships hardened from the first commit: Helmet sets a dozen security headers, Zod rejects malformed request bodies with clear error messages, and express-rate-limit throttles abusive clients with a 429.
Prerequisites
- Node.js 18 or newer (Express 5's minimum). Verified on Node 20.19 with npm 10.
- Versions verified for this tutorial: Express 5.2.1, Helmet 8.3.0, Zod 4.4.3, express-rate-limit 8.6.0.
curlfor testing (preinstalled on macOS and Linux; on Windows use PowerShell 7+ or Git Bash).
1. Scaffold the project
mkdir secure-api && cd secure-api
npm init -y
npm pkg set type=module
npm install express helmet zod express-rate-limit
type=module enables import syntax. All four packages install with zero dependencies flagged by npm audit at these versions.
2. Add security headers with Helmet
Create server.js:
import express from "express";
import helmet from "helmet";
import { rateLimit } from "express-rate-limit";
import * as z from "zod";
const app = express();
app.use(helmet());
app.use(express.json());
app.listen(3000, () => {
console.log("API listening on http://localhost:3000");
});
helmet() alone configures 13 headers — including Content-Security-Policy, Strict-Transport-Security, and X-Content-Type-Options: nosniff — and strips the X-Powered-By: Express fingerprint. Register it before your routes so every response gets covered.
3. Throttle requests with express-rate-limit
Add this above the app.listen call:
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 100, // 100 requests per IP per window
standardHeaders: "draft-8", // modern RateLimit response headers
legacyHeaders: false, // drop the deprecated X-RateLimit-* headers
});
app.use(limiter);
Over the limit, clients get 429 Too Many Requests with a Retry-After header. The default store is in-memory, which is fine for a single process — restarting the server resets all counters.
4. Validate request bodies with Zod
Still above app.listen, add the schema, a reusable middleware, and the routes:
const CreateUser = z.object({
username: z.string().min(3).max(32),
email: z.email(),
age: z.number().int().min(13).optional(),
});
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "Invalid request body",
issues: z.flattenError(result.error).fieldErrors,
});
}
req.body = result.data; // parsed copy: coerced, defaults applied, unknown keys stripped
next();
};
const users = [];
app.post("/users", validate(CreateUser), (req, res) => {
const user = { id: users.length + 1, ...req.body };
users.push(user);
res.status(201).json(user);
});
app.get("/users", (req, res) => {
res.json(users);
});
Two Zod 4 notes: z.email() is now a top-level function (z.string().email() is deprecated), and error.flatten() is replaced by z.flattenError(error).
Verify it works
Start the server with node server.js, then in a second terminal check the headers:
curl -i http://localhost:3000/users
You should see the Helmet and rate-limit headers on a 200 response:
HTTP/1.1 200 OK
Content-Security-Policy: default-src 'self';base-uri 'self';...
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
RateLimit: "100-in-15min"; r=99; t=900
...
[]
Create a user, then send an invalid body:
curl -s -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"username":"ada","email":"ada@example.com","age":36}'
curl -s -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"username":"al","email":"not-an-email"}'
Expected output — a 201 with the new user, then a 400 with field-level errors:
{"id":1,"username":"ada","email":"ada@example.com","age":36}
{"error":"Invalid request body","issues":{"username":["Too small: expected string to have >=3 characters"],"email":["Invalid email address"]}}
Finally, trip the rate limiter:
for i in $(seq 1 101); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/users; done | sort | uniq -c
96 200
5 429
(The earlier curls used part of the 100-request budget; the split varies, but you'll see some 429s.)
Troubleshooting
ValidationError: The 'X-Forwarded-For' header is set but the Express 'trust proxy' setting is false (default)(codeERR_ERL_UNEXPECTED_X_FORWARDED_FOR) — you deployed behind a proxy or load balancer. Addapp.set("trust proxy", 1)before the limiter so it rate-limits the real client IP, not your proxy's.400with empty"issues": {}— you forgot-H "Content-Type: application/json". Express 5 leavesreq.bodyasundefinedwhen the content type doesn't match (Express 4 gave you{}), so Zod rejects the whole body rather than individual fields.[MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file ... is not specified(orSyntaxError: Cannot use import statement outside a moduleon Node < 20.19) —"type": "module"is missing frompackage.json. Runnpm pkg set type=module.Error: listen EADDRINUSE: address already in use :::3000— a previousnode server.jsis still running. Kill it (Ctrl+C, orpkill -f "node server.js") and restart.
Next steps
- Swap the in-memory rate-limit store for Redis with rate-limit-redis so limits survive restarts and work across multiple instances, and add a stricter per-route limiter on auth endpoints.
- Validate
req.paramsandreq.querywith the samevalidatepattern, and infer TypeScript types from schemas withz.infer. - Work through the official Express security best practices — TLS, dependency auditing, and cookie hardening pick up where this setup stops.
Sources & further reading
- Security Best Practices for Express in Production — expressjs.com
- Helmet.js Documentation — helmetjs.github.io
- Zod Basic Usage — zod.dev
- express-rate-limit Usage Guide — express-rate-limit.mintlify.app
Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.
Discussion 0
No comments yet
Be the first to weigh in.