Skip to content
Express.js best practices 5 min read

Security Best Practices

Express gives you a minimal, unopinionated core — which means security is your responsibility, not the framework’s. A handful of disciplines cover the bulk of the OWASP Top 10 for a typical API: set safe HTTP headers, validate and sanitize every input, throttle abusive clients, never build queries by string concatenation, keep secrets out of code, terminate TLS, and audit your dependency tree. None of these are hard; the trap is forgetting one. This page walks through each with runnable Express 4.x (and 5.x-aware) code.

Set secure headers with Helmet

By default Express sends an X-Powered-By: Express header and omits hardening headers entirely. Helmet fixes this in one line by setting a sensible bundle of security headers — Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, and more. Mount it first, before any route.

npm install helmet
import express from 'express';
import helmet from 'helmet';

const app = express();

app.use(helmet()); // applies the full default header set

// Tighten the CSP for an app that only loads its own assets
app.use(
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      imgSrc: ["'self'", 'data:'],
    },
  })
);

A response now carries the protective headers automatically:

Output:

HTTP/1.1 200 OK
Content-Security-Policy: default-src 'self'
Strict-Transport-Security: max-age=15552000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN

Tip: Helmet does not remove X-Powered-By unless mounted — but you can also disable it explicitly with app.disable('x-powered-by'). Hiding the framework banner gives attackers one less hint.

Validate and sanitize all input

Treat every value from the client — body, query, params, headers — as hostile until proven otherwise. Schema validation rejects malformed input at the edge so it never reaches your business logic or database. Libraries like Zod or express-validator make this declarative.

npm install zod
import { z } from 'zod';

const createUserSchema = z.object({
  email: z.string().email(),
  age: z.number().int().min(13).max(120),
  name: z.string().trim().min(1).max(100),
});

function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({ error: 'Validation failed', issues: result.error.issues });
    }
    req.body = result.data; // use the parsed, coerced, trimmed values
    next();
  };
}

app.post('/users', validate(createUserSchema), async (req, res) => {
  const user = await createUser(req.body);
  res.status(201).json(user);
});

Invalid input is rejected before it touches your handler:

Output:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{ "error": "Validation failed", "issues": [ { "path": ["email"], "message": "Invalid email" } ] }

Rate limit to blunt abuse

Without throttling, a single client can brute-force logins or exhaust your resources. express-rate-limit caps requests per IP over a time window. Apply a strict limit to auth endpoints and a looser one globally.

npm install express-rate-limit
import rateLimit from 'express-rate-limit';

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                 // 100 requests per window per IP
  standardHeaders: true,
  legacyHeaders: false,
});

const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 });

app.use('/api', apiLimiter);
app.use('/api/login', loginLimiter);

Warning: Behind a proxy or load balancer, set app.set('trust proxy', 1) so the limiter sees the real client IP from X-Forwarded-For rather than the proxy’s address — otherwise every request looks like one IP.

Use parameterized queries

String-concatenating user input into SQL is the classic injection hole. Always use parameter placeholders so the driver — not your string-building — separates code from data. The same rule applies to NoSQL: never pass raw user objects into query filters.

// BAD — vulnerable to SQL injection
const q = `SELECT * FROM users WHERE email = '${req.body.email}'`;

// GOOD — parameterized; the driver escapes the value
const result = await pool.query(
  'SELECT * FROM users WHERE email = $1',
  [req.body.email]
);

With an ORM like Prisma or a query builder like Knex, parameterization happens automatically — prefer those over hand-written SQL when you can.

Manage secrets safely

Never commit API keys, database passwords, or signing secrets to source control. Load them from environment variables and keep .env files out of git. Use a real secret manager (AWS Secrets Manager, Vault, Doppler) in production.

import 'dotenv/config';

const config = {
  jwtSecret: process.env.JWT_SECRET,
  dbUrl: process.env.DATABASE_URL,
};

// Fail fast at startup if a required secret is missing
for (const [key, value] of Object.entries(config)) {
  if (!value) throw new Error(`Missing required env var for ${key}`);
}

Add .env and .env.* to .gitignore, and validate that all required variables are present at boot so a missing secret crashes startup instead of leaking 500s at runtime.

Enforce HTTPS

All traffic should be encrypted in transit. Terminate TLS at a reverse proxy (Nginx, Caddy) or load balancer, then redirect any plaintext requests and tell browsers to stay on HTTPS via HSTS (Helmet sets this for you).

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

app.use((req, res, next) => {
  if (process.env.NODE_ENV === 'production' && req.protocol !== 'https') {
    return res.redirect(301, `https://${req.headers.host}${req.originalUrl}`);
  }
  next();
});

Audit dependencies

Most production vulnerabilities arrive through transitive dependencies, not your own code. Run npm audit in CI and patch high-severity advisories promptly. Automate updates with Dependabot or Renovate.

npm audit                  # list known advisories
npm audit fix              # apply non-breaking patches
npm audit --audit-level=high --omit=dev  # fail CI on high-severity prod issues

Output:

found 0 vulnerabilities

Header and middleware reference

ConcernToolWhat it does
HTTP headershelmetCSP, HSTS, nosniff, frame options
Input validationzod / express-validatorReject malformed requests at the edge
Rate limitingexpress-rate-limitCap requests per IP per window
CORScorsRestrict which origins may call your API
Secretsdotenv + secret managerKeep credentials out of code

Best Practices

  • Mount helmet() first and app.disable('x-powered-by') to ship safe headers and hide the framework banner.
  • Validate every request body, query, and param with a schema; replace the raw input with parsed, sanitized values.
  • Rate-limit globally and apply a stricter limit to auth endpoints; set trust proxy so limits key on the real client IP.
  • Use parameterized queries or an ORM exclusively — never concatenate user input into SQL or NoSQL filters.
  • Load secrets from environment variables or a secret manager, keep .env out of git, and fail fast at startup if any are missing.
  • Enforce HTTPS everywhere with TLS termination, an HTTPS redirect, and HSTS.
  • Run npm audit in CI and automate dependency updates with Dependabot or Renovate.
Last updated June 14, 2026
Was this helpful?