Express Security Guide

Essential security hardening for Express.js applications covering HTTP headers, rate limiting, input validation, CSRF, and injection prevention.

1. Helmet โ€” Secure HTTP Headers

const helmet = require('helmet');

app.use(helmet()); // applies all defaults

// Custom CSP for SPAs
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net'],
    styleSrc: ["'self'", "'unsafe-inline'", 'fonts.googleapis.com'],
    fontSrc: ["'self'", 'fonts.gstatic.com'],
    imgSrc: ["'self'", 'data:', 'https:'],
    connectSrc: ["'self'", 'api.example.com'],
    frameSrc: ["'none'"],
    objectSrc: ["'none'"],
    upgradeInsecureRequests: [],
  },
}));

// Separate header controls
app.use(helmet.hsts({ maxAge: 63072000, includeSubDomains: true, preload: true }));
app.use(helmet.noSniff());
app.use(helmet.frameguard({ action: 'deny' }));
app.use(helmet.xssFilter());

2. Rate Limiting

const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redis = require('ioredis');

const client = new redis(process.env.REDIS_URL);

// General API limiter
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  store: new RedisStore({ sendCommand: (...args) => client.call(...args) }),
  message: { error: 'Too many requests, try again later.' },
});

// Strict limiter for auth endpoints
const authLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 10,
  skipSuccessfulRequests: true, // only count failures
  message: { error: 'Too many failed attempts.' },
});

app.use('/api/', apiLimiter);
app.post('/auth/login', authLimiter, loginHandler);
app.post('/auth/forgot-password', authLimiter, forgotHandler);

3. Input Validation with Zod

const { z } = require('zod');

// Define schemas
const createUserSchema = z.object({
  email: z.string().email().max(255),
  password: z.string().min(8).max(128).regex(
    /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
    'Must contain uppercase, lowercase, and number'
  ),
  name: z.string().min(1).max(100).trim(),
  age: z.number().int().min(0).max(150).optional(),
});

// Validation middleware factory
function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(422).json({
        error: 'Validation failed',
        fields: result.error.flatten().fieldErrors,
      });
    }
    req.body = result.data; // use parsed (sanitized) data
    next();
  };
}

app.post('/users', validate(createUserSchema), createUser);

4. CSRF Protection

// Modern approach โ€” Double Submit Cookie pattern
const crypto = require('crypto');

function csrfTokenMiddleware(req, res, next) {
  if (!req.cookies['csrf-token']) {
    const token = crypto.randomBytes(32).toString('hex');
    res.cookie('csrf-token', token, {
      httpOnly: false, // JS needs to read it
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
    });
  }
  next();
}

function csrfVerify(req, res, next) {
  const safeMethods = ['GET', 'HEAD', 'OPTIONS'];
  if (safeMethods.includes(req.method)) return next();

  const cookieToken = req.cookies['csrf-token'];
  const headerToken = req.headers['x-csrf-token'];

  if (!cookieToken || cookieToken !== headerToken) {
    return res.status(403).json({ error: 'CSRF token invalid' });
  }
  next();
}

app.use(cookieParser());
app.use(csrfTokenMiddleware);
app.use(csrfVerify);

5. SQL Injection Prevention

// NEVER: string interpolation in queries
// BAD: db.query(`SELECT * FROM users WHERE id = ${req.params.id}`)

// GOOD: Parameterized queries (pg)
const { Pool } = require('pg');
const pool = new Pool();

app.get('/users/:id', async (req, res) => {
  const { rows } = await pool.query(
    'SELECT id, name, email FROM users WHERE id = $1',
    [req.params.id] // parameter, never interpolated
  );
  res.json(rows[0] ?? null);
});

// GOOD: ORM (Prisma)
app.get('/users', async (req, res) => {
  const users = await prisma.user.findMany({
    where: { email: { contains: req.query.search } },
    select: { id: true, name: true, email: true }, // never select passwords
  });
  res.json(users);
});

6. Security Checklist

AreaActionPackage
HTTP HeadersSet secure headershelmet
Rate LimitingThrottle requestsexpress-rate-limit
InputValidate & sanitizezod / joi
PasswordsHash with bcryptbcryptjs
SessionsSecure cookie flagsexpress-session
CORSAllowlist originscors
CSRFDouble-submit cookiecustom / csurf
SQLParameterized queriesORM / pg
SecretsUse env varsdotenv
DependenciesAudit regularlynpm audit