An API that works locally is not automatically safe on the public internet. Production security is a set of layers: the network must be encrypted, every input must be treated as untrusted, identities must be verified, permissions must be enforced, and failures must not reveal internal details.

This guide turns those principles into a practical Express setup. The examples assume Node.js 20 or newer and an API deployed behind a reverse proxy such as Nginx, a cloud load balancer, or an ingress controller.

1. Start with a small, explicit attack surface

Keep the application bootstrap predictable. Disable information that helps fingerprint the server, limit request sizes, and parse only formats your endpoints actually accept.

import express from "express";
import helmet from "helmet";

const app = express();

app.disable("x-powered-by"); app.use(helmet()); app.use(express.json({ limit: "100kb" })); app.use(express.urlencoded({ extended: false, limit: "100kb" }));

A body-size limit prevents a simple request from consuming excessive memory. For large files, use a dedicated upload flow with its own type and size validation instead of increasing the global JSON limit.

2. Terminate HTTPS correctly

Credentials, cookies, and tokens must never travel over plain HTTP in production. Usually TLS terminates at Nginx or a managed load balancer, while Express receives the forwarded request internally.

If the application is behind exactly one trusted proxy, configure Express deliberately:

app.set("trust proxy", 1);

app.use((req, res, next) => { if (process.env.NODE_ENV === "production" && !req.secure) { return res.status(400).json({ error: "HTTPS required" }); } next(); });

Do not enable trust proxy blindly on an app that can also be reached directly. Otherwise a client may forge forwarding headers. Restrict direct access to the application port at the firewall or private-network level.

3. Add security headers with Helmet

Helmet sets a collection of HTTP response headers that reduce common browser-side risks. It is an excellent baseline, but it is not a replacement for authorization or input validation.

app.use(helmet({
  contentSecurityPolicy: false,
  crossOriginResourcePolicy: { policy: "same-site" }
}));

For a JSON-only API, a Content Security Policy may not add much value. If Express also serves HTML, configure the policy for the exact scripts, styles, images, and frames the frontend needs rather than disabling it.

4. Validate and normalize every input

Validate route parameters, query strings, headers, and request bodies at the boundary. Prefer allowlists and strict schemas. Reject unknown fields so a client cannot silently submit properties you did not intend to expose.

import { z } from "zod";

const createUserSchema = z.object({ email: z.string().email().max(254).transform(v => v.toLowerCase()), name: z.string().trim().min(2).max(80), role: z.enum(["member", "editor"]).default("member") }).strict();

app.post("/users", (req, res, next) => { const parsed = createUserSchema.safeParse(req.body); if (!parsed.success) { return res.status(422).json({ error: "Validation failed", details: parsed.error.flatten().fieldErrors }); } req.validatedBody = parsed.data; next(); }, createUser);

Validation protects business logic, but database queries should still use parameterized APIs or an ORM. Never build SQL by concatenating user input.

5. Store passwords safely

Passwords should be hashed with a modern, intentionally slow password-hashing algorithm such as Argon2id or bcrypt with an appropriate work factor. Store only the resulting hash. Never log passwords, password-reset tokens, or raw authentication headers.

import argon2 from "argon2";

const passwordHash = await argon2.hash(password, { type: argon2.argon2id });

const valid = await argon2.verify(user.passwordHash, password);

Return the same generic login error whether the account exists or not. This makes account enumeration more difficult.

6. Separate authentication from authorization

Authentication answers “who is this?” Authorization answers “may this identity perform this action?” A valid token must never imply unlimited access.

function requireRole(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user) return res.status(401).json({ error: "Unauthenticated" });
    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({ error: "Forbidden" });
    }
    next();
  };
}

app.delete("/articles/:id", authenticate, requireRole("admin", "editor"), deleteArticle );

Also enforce ownership when applicable. For example, a member may update only their own profile even if the endpoint path contains a different user ID.

7. Treat tokens as credentials

Use short-lived access tokens, verify the expected signing algorithm, issuer, audience, expiry, and subject, and rotate refresh tokens. Never accept the algorithm from an untrusted token without checking it against an allowlist.

Browser applications can store session or refresh credentials in HttpOnly, Secure, and appropriately configured SameSite cookies. Cookie-based state-changing routes also need CSRF protection when cross-site requests are possible.

8. Rate-limit sensitive routes

Login, password reset, registration, OTP verification, and expensive search endpoints need tighter limits than ordinary reads. In a multi-instance deployment, use a shared store such as Redis so every instance observes the same counters.

import rateLimit from "express-rate-limit";

const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10, standardHeaders: "draft-7", legacyHeaders: false });

app.post("/auth/login", loginLimiter, login);

Rate limiting reduces abuse, but it should not permanently lock a legitimate account. Combine IP, account, and risk signals carefully.

9. Configure CORS as an allowlist

CORS is a browser access policy, not an authentication mechanism. A server-to-server client can ignore it, so protected routes still require authentication and authorization.

import cors from "cors";

const allowedOrigins = new Set([ "https://app.example.com", "https://admin.example.com" ]);

app.use(cors({ origin(origin, callback) { if (!origin || allowedOrigins.has(origin)) return callback(null, true); callback(new Error("Origin not allowed")); }, credentials: true }));

Never combine credentialed requests with a wildcard origin. Keep development origins out of production configuration.

10. Return safe errors and useful logs

Clients need a stable error code and message, not a stack trace, SQL statement, file path, or secret. Logs can contain more context, but must still be redacted.

import crypto from "node:crypto";

app.use((req, res, next) => { req.requestId = req.get("x-request-id") || crypto.randomUUID(); res.set("x-request-id", req.requestId); next(); });

app.use((err, req, res, next) => { console.error({ requestId: req.requestId, method: req.method, path: req.path, error: err.message });

res.status(err.status || 500).json({ error: err.status ? err.message : "Internal server error", requestId: req.requestId }); });

Request IDs make it possible to correlate a client report with application and proxy logs. Add structured logging and alerts for repeated authentication failures, unexpected authorization denials, and spikes in 5xx responses.

11. Secure dependencies and deployment

  • Pin supported Node.js and package versions, keep the lockfile, and review dependency updates.

  • Run the process as a non-root user and expose only the required port.

  • Load secrets from a secret manager or protected environment, never from the repository.

  • Use separate credentials for development, staging, and production.

  • Give the database user only the permissions the application requires.

  • Back up data, test restoration, and define a rotation procedure for compromised secrets.

Production checklist

  1. HTTPS is enforced and the application port is private.

  2. trust proxy matches the real proxy topology.

  3. Helmet and strict request-size limits are enabled.

  4. Every endpoint validates input and applies authorization.

  5. Passwords and tokens are stored, verified, and rotated safely.

  6. Sensitive endpoints have shared-store rate limits.

  7. CORS uses a production allowlist.

  8. Errors are sanitized and logs are structured, redacted, and correlated.

  9. Dependencies, secrets, backups, and restore drills are maintained.

Security is not one middleware. It is the result of consistent boundaries from the edge proxy to the database.

References