Intro
Express is intentionally minimal. That is its power and its risk. Out of the box, an Express 5 app will start quickly, but it is insecure by default unless you add the protections that production APIs require. Today’s threat landscape includes the OWASP API Security Top 10: automated credential stuffing, brute-force login attempts, broken authentication/authorization (including IDOR), SSRF, injection (SQL/NoSQL/command), mass assignment, DoS, supply-chain attacks, and secret leakage. Attackers automate discovery, exploit weak defaults, and pivot through cloud misconfigurations.
This guide is a practical, production-grade reference for hardening Express APIs. For each control we explain the problem, why it matters, show real Express examples, how to verify, and common mistakes.
Reference architecture (secure Express)
[Internet]
|
[WAF/API Gateway]
|
[Reverse Proxy (Nginx/Traefik)] -- mTLS --> [Express 5 API]
| |-- AuthN/Z
| |-- Validation
[Monitoring/Logs] <----- sidecar/agent ----- |-- Rate limits
| |-- DB/Cache (Postgres/Mongo/Redis)
[SIEM/Alerting] |-- Secrets (Vault/SM)
Network and transport controls
- HTTPS and TLS: Terminate TLS at a hardened reverse proxy or gateway. Use TLS 1.2+ and modern ciphers. Redirect HTTP to HTTPS. HSTS locks browsers to HTTPS.
- Reverse proxies: Put Nginx or Traefik in front to enforce TLS, request/response size caps, gzip/brotli, timeouts, and connection limits.
- trust proxy: If you terminate TLS at a proxy, set Express trust proxy correctly so req.ip and secure cookies are reliable.
- HTTP security headers: Helmet consolidates sane defaults; add CSP and HSTS explicitly.
- CORS: Allow only known origins and methods; avoid wildcard credentials.
- Request size limits: Prevent JSON or file body blowups.
- Rate limiting and slow-down: Block bursts and bot traffic before they hit business logic.
Express 5 setup example:
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import slowDown from 'express-slow-down';
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 1); // behind one proxy (e.g., Nginx/ELB)
app.use(helmet({
contentSecurityPolicy: {
useDefaults: true,
directives: { defaultSrc: ["'none'"], frameAncestors: ["'none'"] }
},
crossOriginResourcePolicy: { policy: 'same-site' }
}));
app.use((req, res, next) => {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
next();
});
app.use(cors({
origin: ['https://app.example.com'],
methods: ['GET','POST','PUT','DELETE'],
allowedHeaders: ['Authorization','Content-Type'],
credentials: true,
maxAge: 600
}));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: false, limit: '1mb' }));
const limiter = rateLimit({ windowMs: 60_000, max: 100, standardHeaders: true });
const speed = slowDown({ windowMs: 60_000, delayAfter: 50, delayMs: 250 });
app.use(limiter, speed);
Verify:
- curl -I https://api.example.com checks HSTS and Helmet headers.
- curl -H "Origin:https://evil.com" confirms CORS is blocked.
- ab/hey/k6 simulates load to confirm rate limits.
Common mistakes: not setting trust proxy, wildcard CORS with credentials, missing HSTS preload, large default body limits, placing rate limits after expensive middleware.
Input validation, sanitization, and injection defenses
Why: Most breaches start with unsanitized input. Validate shape, type, range; reject unknown fields to prevent mass assignment.
Zod example:
import { z } from 'zod';
const createUser = z.object({
email: z.string().email(),
password: z.string().min(12).max(128),
role: z.enum(['user','admin']).default('user')
}).strict(); // disallow extras
const validate = (schema) => async (req, res, next) => {
try { req.validated = await schema.parseAsync(req.body); next(); }
catch (e) { res.status(400).json({ error: 'Invalid input', details: e.errors }); }
};
app.post('/users', validate(createUser), async (req, res) => { /* ... */ });
SQL injection (PostgreSQL):
import { Pool } from 'pg';
const pool = new Pool();
const user = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
NoSQL injection (Mongo):
// reject keys starting with $ or containing dots
function rejectOperators(obj) {
for (const k of Object.keys(obj)) if (k.startsWith('$') || k.includes('.')) delete obj[k];
}
rejectOperators(req.body);
const doc = await users.findOne({ email: req.body.email });
XSS: Prefer returning JSON; if rendering HTML, use a templating engine that escapes by default and a strict CSP.
Verify: fuzz with unexpected types and extra fields; run ZAP active scan; attempt ' OR 1=1 -- in parameters.
Common mistakes: trusting client-side validation, using string interpolation for queries, not limiting allowed fields, missing output encoding.
Authentication and authorization
Threats: Broken auth, weak JWT validation, IDOR, session fixation.
JWT (RS256) validation:
import jwt from 'jsonwebtoken';
const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY.replace(/\\n/g, '\n');
function requireAuth(req, res, next) {
const token = (req.headers.authorization || '').replace('Bearer ', '');
try {
const payload = jwt.verify(token, PUBLIC_KEY, {
algorithms: ['RS256'], issuer: 'https://id.example.com/', audience: 'api://orders'
});
req.user = payload; next();
} catch { return res.status(401).json({ error: 'Unauthorized' }); }
}
RBAC/ABAC:
const allow = (check) => (req,res,next) => check(req) ? next() : res.sendStatus(403);
app.get('/admin', requireAuth, allow(r => r.user.roles?.includes('admin')), handler);
app.get('/orders/:id', requireAuth, allow(r => r.user.sub === r.params.id || r.user.roles?.includes('support')), handler);
Sessions and cookies: If using cookie sessions, set Secure, HttpOnly, SameSite=Strict, short TTL, and rotate secrets. Add CSRF protection for stateful browser flows.
CSRF (cookie-based):
import csurf from 'csurf';
app.use(csurf({ cookie: { httpOnly: true, sameSite: 'strict', secure: true } }));
OAuth 2.1 and OpenID Connect: Use a managed provider and openid-client; avoid homegrown auth. Validate issuer, audience, nonce, and PKCE.
API keys: Scope-limited, rotated, stored hashed; send in Authorization: ApiKey <key>.
Verify: tamper with alg=none, wrong aud/iss, expired tokens; try accessing others’ resources (IDOR). Use Postman tests.
Common mistakes: accepting any signing algorithm, not checking aud/iss/exp, missing per-route authorization, long-lived refresh tokens.
Authentication flow:
Client -> (OAuth/OIDC) -> Identity Provider -> JWT -> API -> Resource
Authorization flow:
Request -> AuthN -> Policy (RBAC/ABAC) -> Decision -> Handler -> Response
Secure file uploads
- Use multer with fileSize limits and strict MIME/extension checks; store outside web root; generate random filenames; optionally AV-scan.
- Prefer pre-signed uploads to object storage (S3/GCS) to avoid proxying large files through Node.
Verify: upload oversize/invalid files; ensure server returns 413/415.
Secrets and passwords
- Secret management: .env for local only; use Vault/AWS Secrets Manager/GCP Secret Manager in prod; bind via environment or mounted files; rotate.
- Password hashing: Argon2id with strong params; avoid plaintext or raw bcrypt defaults.
import argon2 from 'argon2';
const hash = await argon2.hash(password, { type: argon2.argon2id, timeCost: 3, memoryCost: 65536 });
const ok = await argon2.verify(hash, passwordAttempt);
Common mistakes: committing .env to Git, reusing API keys, weak hash params, logging secrets.
Observability: logging, audit, monitoring, alerting
- Logging: use pino/winston; include request-id, user-id; redact Authorization and cookies.
- Audit trails: immutable logs of auth, admin, data export/delete.
- Monitoring: metrics (prom-client), SLIs (latency, error rate), health and readiness probes; set alerts.
Logging/monitoring architecture:
Express -> JSON logs -> Collector/Sidecar -> Central Log Store/SIEM
-> /metrics -> Prometheus -> Grafana Alerts
Supply chain and platform hardening
- Dependency management: lockfiles, npm ci, weekly updates (Renovate), remove abandoned packages. Run npm audit and Snyk in CI.
- Docker: node:18-alpine or distroless, non-root user, read-only FS, drop capabilities, minimal attack surface.
- Kubernetes: NetworkPolicies, Secrets, PodSecurity, resource limits, HPA, liveness/readiness/startup probes, mTLS in mesh.
- Edge: API Gateway and WAF for TLS, JWT verification, rate limits, IP allow/deny, bot management.
- Zero Trust: authenticate and authorize every hop; short-lived tokens; least-privilege network and cloud roles.
Zero Trust API architecture:
Client -(TLS)-> Gateway -(mTLS/JWT)-> Service A -(mTLS)-> Service B
^ IAM & Policy ^ per-request authZ ^ data-level checks
Architecture recommendations
- Reverse proxy: Nginx or Traefik terminating TLS, enforcing limits, proxying to Express.
- Data tier: PostgreSQL (SQL, roles, RLS), MongoDB (auth, TLS), Redis (TLS, ACLs).
- Orchestrators: Docker with non-root images; Kubernetes with Gateway API/Ingress, secrets manager integration.
- Identity: OAuth/OIDC provider (Auth0, Okta, Azure AD, Keycloak) issuing JWTs for the API.
- Observability: Centralized logging (ELK/OpenSearch), metrics (Prometheus), tracing (OpenTelemetry), alerting (Grafana/Alertmanager).
Reverse proxy + Express:
Client -> Nginx/Traefik -> Express (trust proxy=1) -> App routes
Security testing
- curl: check headers and CORS.
- OWASP ZAP/Burp Suite: active scan, auth-protected scans.
- Nmap: nmap --script ssl-enum-ciphers -p 443 api.example.com
- Postman: auth tests, negative cases.
- npm audit, snyk test: CI supply-chain checks.
Quick comparisons
Secure vs insecure implementation:
Insecure: HTTP, wildcard CORS, no rate limit, raw SQL, no validation
Secure: HTTPS+HSTS, strict CORS, rate+slowdown, params queries, Zod/Joi
Dev vs prod config:
Dev: self-signed TLS, verbose logs, seed data
Prod: mTLS/gateway, redacted logs, migrations, secrets manager
JWT vs sessions:
JWT: stateless, great for APIs, careful validation
Sessions: stateful, good for browsers, needs CSRF + store
API keys vs OAuth:
Keys: simple, per-service, rotate/limit scope
OAuth: delegated user auth, scopes, revocation, audit
Open vs hardened proxy:
Open: TLS defaults, no limits, no headers
Hardened: modern ciphers, size/time limits, security headers
Helmet on vs off:
Off: missing HSTS/CSP/noSniff
On: sane defaults, fewer classes of bugs
Restrictive vs permissive CORS:
Permissive: * with credentials -> account takeover risk
Restrictive: allowlist origins, methods, headers, short maxAge
Common production mistakes
- Trusting user input; skipping validation.
- Exposing stack traces in production.
- Storing secrets in Git or images.
- Weak JWT validation (missing iss/aud/exp, accepting alg=none).
- Missing authorization checks (IDOR).
- Overly permissive CORS.
- No rate limits or request caps.
- Disabled HTTPS or wrong trust proxy.
- Outdated dependencies and unpatched images.
Performance vs security
- Middleware overhead: place cheap rejections (limits, CORS) early; avoid heavy parsing until necessary.
- Caching: cache GETs at proxy; cache JWKS/keys; ETag/Cache-Control.
- JWT validation cost: prefer RS256 with cached public keys; avoid introspection per request.
- Rate limiting: use in-memory for small scale; Redis-backed for clusters.
- Logging: structured, sampled at scale; avoid synchronous file writes.
- Reverse proxy: enable HTTP/2, keep-alive tuning, gzip/brotli, connect timeouts.
Production roadmap
- Local: strict validation, Helmet, CORS, limits, auth stub, pino logs, npm audit.
- Staging: reverse proxy, TLS, rate limits, real IdP, Prometheus/Grafana, ZAP scans.
- Production: Gateway/WAF, secrets manager, HPA, SIEM, alerting, runbooks, DR.
- Enterprise: Zero Trust, mTLS service mesh, continuous SCA/SAST/DAST, least privilege IAM, regular pen-tests.
Conclusion
Express gives you a fast core; production-grade security is everything you add around it. Start with transport protections (TLS, proxy, headers), enforce strong validation and injection defenses, implement robust authentication/authorization, control resource abuse with limits and rate controls, manage secrets and passwords correctly, and invest in observability and supply-chain hygiene. Verify with repeatable tests and automate in your DevSecOps pipeline. The result is an Express API that is fast, predictable, and resilient under real-world attack pressure.