Skip to content
Dev Tools Beginner Tutorial

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.

Lenn Voss
Lenn Voss
Cloud & Infrastructure Writer · Jul 25, 2026 · 4 min read
Build a Secure Express API with Helmet, Zod, and Rate Limiting

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.
  • curl for 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) (code ERR_ERL_UNEXPECTED_X_FORWARDED_FOR) — you deployed behind a proxy or load balancer. Add app.set("trust proxy", 1) before the limiter so it rate-limits the real client IP, not your proxy's.
  • 400 with empty "issues": {} — you forgot -H "Content-Type: application/json". Express 5 leaves req.body as undefined when 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 (or SyntaxError: Cannot use import statement outside a module on Node < 20.19) — "type": "module" is missing from package.json. Run npm pkg set type=module.
  • Error: listen EADDRINUSE: address already in use :::3000 — a previous node server.js is still running. Kill it (Ctrl+C, or pkill -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.params and req.query with the same validate pattern, and infer TypeScript types from schemas with z.infer.
  • Work through the official Express security best practices — TLS, dependency auditing, and cookie hardening pick up where this setup stops.

Sources & further reading

  1. Security Best Practices for Express in Production — expressjs.com
  2. Helmet.js Documentation — helmetjs.github.io
  3. Zod Basic Usage — zod.dev
  4. express-rate-limit Usage Guide — express-rate-limit.mintlify.app
Lenn Voss
Written by
Lenn Voss · Cloud & Infrastructure Writer

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

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading