CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/express-security-basics

Security defaults that belong in every Express application from day one.

93

6.18x
Quality

90%

Does it follow best practices?

Impact

99%

6.18x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/express-security-basics/

name:
express-security-basics
description:
Security defaults that belong in every Express application from day one. CORS, Helmet, rate limiting, trust proxy, input limits, and secure error handling. Apply these whenever you create or modify any Express app -- do not wait for a security review or explicit request. If you are writing app.use(express.json()), you must also add these six middleware.
keywords:
express, express.js, rest api, api, cors, helmet, rate limiting, security headers, express-rate-limit, trust proxy, error handler, middleware, production, deployment, node.js, typescript
license:
MIT

Express Security Basics

Every Express app needs these security defaults from day one -- do not wait for a security review to add them. These are not optional hardening steps; they are baseline requirements for any Express application, the same way you would not ship a car without seatbelts.

When to apply this skill: Every time you create a new Express application, add routes to an existing one, or refactor an Express project. Even if the task only asks you to "build a REST API" or "create CRUD endpoints," you MUST include all six of these middleware. An Express app without them is incomplete.


The 6 Things Every Express App Needs

  1. Helmet -- security headers (one line)
  2. CORS -- configured with explicit origins, not wide open
  3. Rate limiting -- on all API routes, stricter on auth and mutation endpoints
  4. Input limits -- body size and type restrictions
  5. Trust proxy -- correct client IP behind reverse proxies
  6. Secure error handler -- never leak stack traces to clients

These six items are as fundamental as express.json(). If your app parses JSON but lacks these, it is not ready for any environment -- not development, not staging, not production.


1. Security Headers (Helmet)

npm install helmet

WRONG -- no security headers at all:

const app = express();
app.use(express.json());
// Routes defined here without any security headers

RIGHT -- Helmet before all routes:

import helmet from 'helmet';

const app = express();
app.use(helmet()); // MUST be before all route handlers
app.use(express.json());

Helmet adds these security headers by default:

  • Content-Security-Policy -- prevents XSS by restricting resource origins
  • Cross-Origin-Opener-Policy -- isolates browsing context
  • Cross-Origin-Resource-Policy -- blocks cross-origin reads
  • X-Content-Type-Options: nosniff -- prevents MIME-type sniffing
  • X-Frame-Options: SAMEORIGIN -- prevents clickjacking
  • X-DNS-Prefetch-Control: off -- disables DNS prefetching
  • Strict-Transport-Security -- enforces HTTPS for 180 days
  • X-Download-Options: noopen -- prevents IE file downloads from executing
  • X-Permitted-Cross-Domain-Policies: none -- blocks Flash/PDF cross-domain
  • X-Powered-By is REMOVED (hides Express fingerprint)
  • X-XSS-Protection: 0 -- disables broken legacy XSS filter

Customize CSP when needed

WRONG -- disabling CSP entirely:

app.use(helmet({
  contentSecurityPolicy: false, // Never disable CSP
}));

RIGHT -- customize directives for your app's needs:

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https://cdn.example.com"],
    }
  }
}));

For API-only servers that never serve HTML, you can relax CSP since there is no browser document to protect:

app.use(helmet({
  contentSecurityPolicy: false, // OK for pure JSON APIs behind a gateway
}));

2. CORS -- Configured, Not Wide Open

npm install cors

WRONG -- allows any origin (this is the default):

app.use(cors()); // Equivalent to Access-Control-Allow-Origin: *

WRONG -- reflecting the request origin (even worse than wildcard):

app.use(cors({
  origin: true, // Reflects any origin, allows credentials -- very dangerous
  credentials: true,
}));

RIGHT -- explicit allowed origins:

import cors from 'cors';

app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:5173'],
  methods: ['GET', 'POST', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,  // Only if you use cookies/sessions
}));

RIGHT -- dynamic origin validation for multiple environments:

const allowedOrigins = new Set(
  (process.env.ALLOWED_ORIGINS || 'http://localhost:5173').split(',')
);

app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (server-to-server, curl, etc.)
    if (!origin || allowedOrigins.has(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
}));

When serving frontend from the same server

If Express serves both the API and static files (common in prototypes), you may not need CORS at all -- same-origin requests do not trigger CORS. Only add it if the frontend is on a different port or domain.

// Only add CORS if frontend is on a separate origin
if (process.env.FRONTEND_URL) {
  app.use(cors({ origin: process.env.FRONTEND_URL }));
}

3. Rate Limiting

npm install express-rate-limit

WRONG -- no rate limiting at all:

app.post('/api/auth/login', loginHandler); // Unlimited login attempts

RIGHT -- general API rate limit plus stricter limits for sensitive and mutation endpoints:

import rateLimit from 'express-rate-limit';

// General API rate limit (applies to all /api routes)
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,                    // 100 requests per window per IP
  standardHeaders: true,       // Return RateLimit-* headers
  legacyHeaders: false,        // Disable X-RateLimit-* headers
  message: {
    error: { code: 'RATE_LIMITED', message: 'Too many requests, try again later' }
  }
});

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

// Stricter limit for auth endpoints (prevent brute force)
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 10,                     // 10 attempts per window
  standardHeaders: true,
  legacyHeaders: false,
  message: {
    error: { code: 'RATE_LIMITED', message: 'Too many login attempts, try again later' }
  }
});

app.use('/api/auth', authLimiter);

// Stricter limit for mutation/write endpoints (POST, PATCH, DELETE)
const mutationLimiter = rateLimit({
  windowMs: 60 * 1000,        // 1 minute
  max: 20,                     // 20 writes per minute
  standardHeaders: true,
  legacyHeaders: false,
  message: {
    error: { code: 'RATE_LIMITED', message: 'Write rate limit exceeded' }
  }
});

// Apply to specific write endpoints
app.post('/api/orders', mutationLimiter);
app.patch('/api/orders/:id', mutationLimiter);
app.delete('/api/orders/:id', mutationLimiter);

Always apply a stricter rate limit on mutation endpoints (POST, PATCH, DELETE) than the general read limit. Write operations are more expensive and more dangerous when abused.

Production: use an external store

WRONG -- using the default memory store in production with multiple processes:

// Memory store does NOT share state across cluster workers or multiple instances
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 });
// Each process tracks limits independently -- ineffective at scale

RIGHT -- use Redis or another external store in production:

import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';

const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args: string[]) => redisClient.sendCommand(args),
  }),
});

4. Trust Proxy

When Express runs behind a reverse proxy (nginx, AWS ALB, Cloudflare, etc.), req.ip returns the proxy's IP, not the client's. This breaks rate limiting and IP logging.

WRONG -- rate limiting sees the proxy IP, so all users share one limit:

const app = express();
// trust proxy not set -- req.ip is always the load balancer IP
app.use(rateLimit({ windowMs: 60000, max: 10 })); // Blocks ALL users after 10 total requests

RIGHT -- tell Express how many proxies to trust:

const app = express();

// Behind one proxy (nginx, ALB, etc.)
app.set('trust proxy', 1);

// Now req.ip reads from X-Forwarded-For correctly
app.use(rateLimit({ windowMs: 60000, max: 10 })); // Per-client limiting works

WRONG -- trusting all proxies blindly:

app.set('trust proxy', true); // Client can spoof X-Forwarded-For -- never do this in production

Set trust proxy to the exact number of proxies between the client and Express (1 for a single reverse proxy, 2 for CDN + load balancer, etc.), or use 'loopback' for localhost proxies.


5. Input Limits and Validation

WRONG -- accepting unlimited body sizes and parsing unnecessary formats:

app.use(express.json()); // Default limit is 100kb -- often too generous
app.use(express.urlencoded({ extended: true })); // Don't enable unless you actually need form submissions

RIGHT -- restrict body size and only parse what you need:

app.use(express.json({ limit: '10kb' }));
// Only add urlencoded parsing if your API actually accepts form-encoded data.
// Most JSON APIs do not need it. Remove it if unused -- it's extra attack surface.
// app.use(express.urlencoded({ extended: false, limit: '10kb' }));

When reviewing existing code, if you see express.urlencoded() but the API only accepts JSON, remove it or comment it out. Every parser you enable is additional attack surface.

Parameter validation

WRONG -- trusting user input directly:

router.get('/api/users/:id', async (req, res) => {
  const user = await db.query(`SELECT * FROM users WHERE id = ${req.params.id}`); // SQL injection
  res.json(user);
});

RIGHT -- validate and sanitize all input:

router.get('/api/users/:id', (req, res) => {
  const id = parseInt(req.params.id, 10);
  if (isNaN(id) || id <= 0) {
    return res.status(400).json({
      error: { code: 'INVALID_PARAM', message: 'Invalid user ID' }
    });
  }
  // Use parameterized queries
  const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
  res.json(user);
});

Preventing HTTP parameter pollution

WRONG -- accepting duplicate query parameters without handling:

// GET /api/search?sort=name&sort=; DROP TABLE users--
// req.query.sort could be an array, causing unexpected behavior
router.get('/api/search', (req, res) => {
  const results = db.query(`SELECT * FROM items ORDER BY ${req.query.sort}`);
});

RIGHT -- use hpp middleware and validate query params:

npm install hpp
import hpp from 'hpp';

app.use(hpp()); // Picks last value for duplicate params, puts array in req.query.polluted

// Always validate and whitelist query parameter values
router.get('/api/search', (req, res) => {
  const allowedSortFields = ['name', 'date', 'price'];
  const sort = allowedSortFields.includes(req.query.sort as string) ? req.query.sort : 'date';
  // Use parameterized queries with validated values
});

6. Secure Error Handler

WRONG -- leaking stack traces and internal details:

app.use((err, req, res, next) => {
  res.status(500).json({
    error: err.message,
    stack: err.stack,      // Exposes file paths, line numbers, dependencies
    query: req.query,      // Echoes back user input
  });
});

WRONG -- no custom error handler (Express default shows HTML stack in dev):

// No error handler defined -- Express shows full stack trace in development mode

RIGHT -- safe error handler that logs internally but returns minimal info:

// Error handler MUST have 4 parameters (err, req, res, next) to be recognized by Express
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
  // Log full error internally
  console.error(`[${new Date().toISOString()}] ${req.method} ${req.path}`, err);

  // Never send stack traces or internal details to the client
  const statusCode = (err as any).statusCode || 500;
  res.status(statusCode).json({
    error: {
      code: statusCode === 500 ? 'INTERNAL_ERROR' : (err as any).code || 'ERROR',
      message: statusCode === 500 ? 'An unexpected error occurred' : err.message,
    }
  });
});

7. Session Cookie Security (When Using Sessions)

If your app uses sessions (express-session, cookie-session), configure cookies securely.

WRONG -- insecure session cookies:

app.use(session({
  secret: 'keyboard cat',  // Weak, hardcoded secret
  cookie: {},               // No security flags set
}));

RIGHT -- secure session configuration:

app.use(session({
  secret: process.env.SESSION_SECRET,         // Strong secret from environment
  name: '__Host-sid',                          // Custom name, __Host- prefix enforces HTTPS + no subdomain
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production',  // HTTPS only in production
    httpOnly: true,                                   // Not accessible via JavaScript
    sameSite: 'lax',                                  // Prevents CSRF on cross-site GET
    maxAge: 24 * 60 * 60 * 1000,                      // 24 hours
  }
}));

Key cookie flags:

  • httpOnly: true -- prevents document.cookie access (blocks XSS cookie theft)
  • secure: true -- cookie only sent over HTTPS
  • sameSite: 'lax' -- prevents CSRF by blocking cross-site POST requests
  • sameSite: 'strict' -- blocks all cross-site requests (breaks OAuth redirects)

Middleware Order

The order middleware is registered matters. Security middleware MUST come first.

const app = express();

// 1. Trust proxy (before anything that reads req.ip)
app.set('trust proxy', 1);

// 2. Security headers
app.use(helmet());

// 3. CORS
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }));

// 4. Rate limiting
app.use('/api', apiLimiter);
app.use('/api/auth', authLimiter);

// 5. Body parsing with limits
app.use(express.json({ limit: '10kb' }));

// 6. Parameter pollution protection
app.use(hpp());

// 7. Routes
app.use('/api', apiRouter);

// 8. Error handler (MUST be last)
app.use(errorHandler);

Checklist -- Apply to Every Express App

This is not a "production checklist." These belong in every Express app from the start:

  • helmet() middleware registered before all routes
  • CORS configured with explicit origins (never cors() with no arguments)
  • Rate limiting on all API endpoints, stricter on auth and mutation routes
  • trust proxy set to the correct number of proxy hops
  • express.json({ limit: '10kb' }) -- body size restricted
  • Custom error handler that never leaks stack traces
  • HTTPS enforced (Helmet HSTS + load balancer config)

When using sessions:

  • Session cookies have httpOnly: true, secure: true, sameSite: 'lax'
  • Session secret loaded from environment, not hardcoded

Recommended extras:

  • hpp() middleware to prevent parameter pollution
  • Input validation on all route params and query strings
  • External rate limit store (Redis) for multi-process deployments

If the task says "build a REST API" or "create CRUD endpoints" and does not mention security, you still add all of the above. Security middleware is not a feature request -- it is part of building an Express app correctly.

Verifiers

  • helmet-enabled -- Helmet middleware on every Express app
  • cors-configured -- CORS with explicit origins on every Express app
  • rate-limiting-added -- Rate limiting on every Express app
  • trust-proxy-configured -- Trust proxy on every Express app behind a proxy
  • secure-error-handler -- Secure error handler on every Express app
  • input-limits-set -- Body size limits on every Express app

skills

express-security-basics

tile.json