Intro
Express is fast to start, but small configuration mistakes can quietly undermine security, reliability, and debuggability. Typical symptoms include cookies that refuse to be secure behind a proxy, browsers blocked by misapplied CORS, sudden 413 "Payload Too Large" responses, or error stacks leaking to users. The good news: most of these issues have straightforward, low-risk fixes that you can apply and verify in minutes.
This guide shows you how to inventory your current setup, adopt a safe baseline configuration, verify observable outcomes, and prepare rollback paths before you ship. The examples use plain Express with no assumptions about specific frameworks or deployment tools.
Version and Environment Inventory
Before changing configuration, capture a snapshot of what exists now. This makes debugging and rollback simple.
- Record runtime and library versions:
node -vnpm ls expressnpm ls body-parser helmet cors morgan- Note topology: are you behind a reverse proxy or load balancer? Does TLS terminate before Express? If yes,
trust proxybehavior matters. - Capture critical environment variables:
NODE_ENV,PORT,HOST,TRUST_PROXY,CORS_ORIGINS,JSON_LIMIT,LOG_LEVEL,SESSION_SECURE, and any feature flags. - Save current app settings (if available):
app.get('env'),app.get('trust proxy'),app.get('json spaces'),app.get('x-powered-by').
Example commands and quick checks:
node -vnpm ls expresscurl -i http://localhost:3000/healthcurl -i -X OPTIONS http://localhost:3000/any-route -H "Origin: https://example.com" -H "Access-Control-Request-Method: GET"
Use the table below as a quick inventory reference.
| Item | How to check | Example expected |
|---|---|---|
| Node.js version | node -v | v18.x or later |
| Express version | npm ls express | [email protected] or 5.x |
| Reverse proxy present | team/network notes | yes/no |
| NODE_ENV | echo $NODE_ENV | production (for prod) |
| Trust proxy | log app.get('trust proxy') | true/number/function |
| CORS origins | echo $CORS_ORIGINS | https://app.example.com |
| JSON body limit | echo $JSON_LIMIT | 1mb |
| Port/host | echo $PORT $HOST | 3000 0.0.0.0 |
Safe Configuration Path
A safe baseline reduces surprises and makes behavior observable. The examples below are constructed to illustrate patterns; tailor numbers and lists to your system.
1) Validate environment early and fail fast
Mistake: Relying on implicit defaults and running with missing or invalid environment variables.
Safe change: Use a small validator to reject bad configurations at startup. Example using a simple hand-rolled check (no external libraries required):
// config.js
function requireEnv(name, fallback) {
const v = process.env[name] ?? fallback;
if (v === undefined || v === '') throw new Error(`Missing env: ${name}`);
return v;
}
function parseBool(v, def = false) {
if (v == null) return def;
return /^(1|true|yes)$/i.test(v);
}
const cfg = {
env: process.env.NODE_ENV || 'development',
port: Number(requireEnv('PORT', 3000)),
host: process.env.HOST || '0.0.0.0',
trustProxy: process.env.TRUST_PROXY || '0', // '0', '1', 'true', or IP list
corsOrigins: (process.env.CORS_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean),
jsonLimit: process.env.JSON_LIMIT || '1mb',
logLevel: process.env.LOG_LEVEL || 'info',
sessionSecure: parseBool(process.env.SESSION_SECURE, false),
};
module.exports = cfg;
2) Correct middleware order
Mistake: Loading middleware in the wrong order, causing requests to skip parsers or lose headers.
Safe change: Use a clear top-to-bottom order: security headers, logging, rate limits (if any), parsers, CORS, routes, 404, error handler.
// app.js
const express = require('express');
const helmet = require('helmet');
const morgan = require('morgan');
const cors = require('cors');
const cfg = require('./config');
const app = express();
// 1) Security headers
app.disable('x-powered-by');
app.use(helmet());
// 2) Logging (ensure it runs for all requests)
app.use(morgan(cfg.env === 'production' ? 'combined' : 'dev'));
// 3) Trust proxy (before anything that depends on req.ip or req.secure)
if (cfg.trustProxy === 'true' || cfg.trustProxy === '1') {
app.set('trust proxy', 1); // behind one proxy
} else if (cfg.trustProxy !== '0') {
app.set('trust proxy', cfg.trustProxy); // e.g., 'loopback' or IPs
}
// 4) Body parsers with explicit limits
app.use(express.json({ limit: cfg.jsonLimit, strict: true, type: 'application/json' }));
app.use(express.urlencoded({ extended: false, limit: cfg.jsonLimit }));
// 5) CORS (locked to known origins)
const allowOrigins = new Set(cfg.corsOrigins);
app.use(cors({
origin: function(origin, cb) {
if (!origin) return cb(null, false); // disallow non-browser or unknown origins by default
return cb(null, allowOrigins.has(origin));
},
credentials: true,
methods: ['GET','POST','PUT','PATCH','DELETE','OPTIONS'],
allowedHeaders: ['Content-Type','Authorization'],
maxAge: 600
}));
// 6) Example route
app.get('/health', (req, res) => {
res.json({ status: 'ok', secure: req.secure, ip: req.ip });
});
// 7) 404 handler
app.use((req, res, next) => {
res.status(404).json({ error: 'Not Found' });
});
// 8) Centralized error handler (no stack traces in production)
app.use((err, req, res, next) => {
const status = err.status || 500;
const payload = { error: err.message || 'Internal Server Error' };
if (cfg.env !== 'production') payload.stack = err.stack;
res.status(status).json(payload);
});
module.exports = app;
3) Server timeouts are explicit
Mistake: Relying on default HTTP server timeouts that are too long for clients, leaving sockets hanging.
Safe change: Set explicit timeouts and keep-alives appropriate for your API latency budget.
// server.js
const http = require('http');
const app = require('./app');
const cfg = require('./config');
const server = http.createServer(app);
server.setTimeout(15_000); // 15s request timeout
server.headersTimeout = 18_000; // header timeout slightly above setTimeout
server.keepAliveTimeout = 5_000; // keep-alive as needed
server.listen(cfg.port, cfg.host, () => {
console.log(`API on http://${cfg.host}:${cfg.port} env=${cfg.env}`);
});
4) Trust proxy and secure cookies
Mistake: Setting secure cookies or relying on req.secure without enabling trust proxy when behind a reverse proxy. The app thinks requests are not secure and downgrades behavior.
Safe change: If your TLS terminates at a proxy, set app.set('trust proxy', 1) (or an appropriate function) so Express honors X-Forwarded-* headers. Example cookie setup:
const cookieParser = require('cookie-parser');
const cfg = require('./config');
app.use(cookieParser());
// Example cookie set
app.get('/login-demo', (req, res) => {
res.cookie('sid', 'example', {
httpOnly: true,
secure: cfg.sessionSecure, // true in HTTPS environments
sameSite: 'lax',
path: '/',
});
res.json({ ok: true });
});
If secure cookies stop appearing after this change, verify trust proxy and whether requests are truly HTTPS at the proxy.
5) CORS that matches your surface area
Mistake: Using wildcard origins with credentials or forgetting preflight handling, causing browser failures.
Safe change: Use a strict allowlist and confirm headers with curl.
Expected headers for allowed origin:
Access-Control-Allow-Origin: https://app.example.comVary: OriginAccess-Control-Allow-Credentials: true
6) Bounded body sizes
Mistake: Default body size limits that are too small (rejecting valid requests) or too large (risking memory pressure).
Safe change: Set express.json({ limit: '1mb' }) or a size that fits your largest expected JSON payload. Investigate spikes before raising limits.
7) Consistent logging without secrets
Mistake: Verbose logs in production leaking tokens or PII; no request IDs; no format differences across environments.
Safe change: Use morgan for request logs and mask sensitive fields in custom tokens if needed. Keep logs at info level in production and debug locally.
const morgan = require('morgan');
morgan.token('reqid', (req) => req.headers['x-request-id'] || '-');
app.use(morgan(':reqid :method :url :status :res[content-length] - :response-time ms'));
8) Centralized error handling
Mistake: Throwing errors in route handlers without a catch, or leaking stack traces in production.
Safe change: Always forward to next(err) or throw within async handlers wrapped by a helper; ensure a final error handler responds with sanitized messages in production.
function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
app.get('/example', asyncHandler(async (req, res) => {
// ...
res.json({ ok: true });
}));
Verification and Diagnostics
Use observable checks to confirm behavior after each change.
1. Headers and security basics
curl -i http://localhost:3000/health- Expect:
X-DNS-Prefetch-Control,X-Frame-Options,X-Content-Type-Optionsfrom helmet. Expect noX-Powered-Byheader.
2. CORS allowlist
curl -i -X OPTIONS http://localhost:3000/health \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: GET"
- Expect: 204 or 200 with
Access-Control-Allow-Origin: https://app.example.comandAccess-Control-Allow-Credentials: true. - Try an unlisted origin and expect either no CORS headers or a 403 depending on your policy.
3. Trust proxy and HTTPS awareness
curl -i http://localhost:3000/health -H "X-Forwarded-Proto: https" -H "X-Forwarded-For: 203.0.113.7"
- If behind a proxy, simulate forwarded headers:
- Expect: JSON showing
secure: trueand a client IP rather than only the proxy IP (depending on trust proxy setting).
4. Body size limits
- Send a payload slightly below your limit and expect 200. Then send one above to confirm 413.
Example (constructed sizes):
import requests, json
payload_ok = 'x' * (1024 * 100) # 100 KiB
payload_big = 'x' * (1024 * 2048) # 2 MiB
print('OK', requests.post('http://localhost:3000/echo', json={'d': payload_ok}).status_code)
print('BIG', requests.post('http://localhost:3000/echo', json={'d': payload_big}).status_code)
5. Error handler behavior
curl -i http://localhost:3000/boom
- Trigger an error and confirm redaction in production.
- Expect: 500 with
{ "error": "Internal Server Error" }in production; stack only in non-production.
6. Timeouts
- Use a test route that delays response beyond
server.setTimeoutand confirm the client gets a timeout and the server logs a timeout event.
Common Mistakes and Safe Fixes (Quick Reference)
The table below summarizes frequent pitfalls. All rows are constructed examples.
| Mistake | Symptom | Safe change | Quick check |
|---|---|---|---|
| Missing trust proxy | secure cookies not set; req.secure false | app.set('trust proxy', 1) behind a single proxy | curl with X-Forwarded-Proto: https shows secure: true |
| Wildcard CORS with credentials | Browsers reject; CORS errors in console | Use an allowlist and return exact origin | OPTIONS preflight returns allowed origin only |
| Unbounded body size | Memory spikes; DoS risk | express.json({ limit: '1mb' }) | Payload > limit returns 413 |
| No centralized error handler | HTML error dumps; stacks leak | Final error middleware; redact in prod | 500 response has no stack in prod |
| Defaults for timeouts | Hanging sockets; slow-loris risk | server.setTimeout(...) and headers/keepalive | Long-running route is cut off as expected |
| Verbose prod logs | Sensitive data in logs | Use morgan format; avoid bodies/tokens | Request logs show minimal fields |
Failure Modes and Recovery
1. Trust proxy mis-set
- Failure: After enabling trust proxy,
req.ipshows a private proxy IP orsecurebecomes true for HTTP traffic you did not expect. - Diagnosis: Log
req.ip,req.ips,req.secure, andX-Forwarded-*headers; compare to expected network path. - Recovery: Revert
app.set('trust proxy')to previous value (0 or false), or tighten to the correct hop count. Keep an environment variable (TRUST_PROXY) so rollback is a single env change and restart.
2. Overly strict CORS
- Failure: Browsers fail with CORS errors after deploying an allowlist.
- Diagnosis: Inspect the browser request
Originand compare with the allowlist; run curl preflight. - Recovery: Temporarily add the missing origin to
CORS_ORIGINS, or disable strict mode in non-production to triage. Do not use wildcard with credentials.
3. Body limit too small
- Failure: Clients get 413 Payload Too Large for legitimate requests.
- Diagnosis: Inspect
Content-Lengthand logs; confirmexpress.jsonlimit setting. - Recovery: Raise limit incrementally (e.g., 1mb -> 2mb) and add monitoring for memory usage. Keep limits small by default and escalate only for specific routes if needed.
4. Timeout too aggressive
- Failure: Normal requests intermittently fail with network timeouts.
- Diagnosis: Compare route latency percentiles to
server.setTimeoutvalue. - Recovery: Increase timeout slightly and optimize long-running handlers. Consider streaming or background processing for truly long tasks.
5. Error handler reveals stacks in production
- Failure: Users see stack traces; bots capture implementation details.
- Diagnosis:
NODE_ENVnot set to production; error handler includes stack unconditionally. - Recovery: Set
NODE_ENV=productionand gate stack output behind an environment check. Add a CI/startup assertion that production rejects unsafe settings.
6. Logging overload
- Failure: High CPU or disk usage; log ingestion costs spike.
- Diagnosis: Volume jump correlates with log level or new structured fields.
- Recovery: Reduce log level, remove noisy logs, and roll logs. Keep a per-env
LOG_LEVELvar so rollback is a restart away.
Rollback Habits That Work
- Use environment variables for toggles (
TRUST_PROXY,JSON_LIMIT,LOG_LEVEL). Rollbacks are then env flips plus a restart. - Keep a minimal previous-known-good config snippet checked in alongside the app.
- For each change, write a one-paragraph change note with before/after and the verification command you used.
- Test the rollback path before rollout: flip the toggle locally and confirm behavior reverts.
Operations Checklist
Use this to plan, execute, and verify configuration changes. Items are constructed examples.
1. Plan
- Capture versions:
node -v,npm ls express - Identify topology: is there a reverse proxy? Where does TLS terminate?
- Declare intended change and expected observable (header, status, log, or metric)
2. Prepare
- Add or confirm environment validation in
config.js - Add env toggles for the change
- Write a curl or browser test to verify behavior
3. Change
- Apply the smallest viable edit (e.g.,
app.set('trust proxy', 1)) - Restart the process in a controlled environment
4. Verify
- Run the prepared curl tests and record outputs
- Check logs for expected lines and absence of errors
- Confirm no regressions on key endpoints (
/health, auth, uploads)
5. Monitor
- Watch error rates, latency, and resource use for 30-60 minutes (constructed guidance)
6. Rollback (if needed)
- Flip the env toggle back
- Restart
- Re-run verification to confirm previous behavior
Conclusion
Express is flexible, but that flexibility invites subtle configuration mistakes. A safe baseline goes a long way: validate environment early, order middleware intentionally, set trust proxy correctly, lock down CORS, bound request sizes, add a centralized error handler, and configure explicit timeouts and logs. Treat each change as a small, measurable experiment you can verify locally and roll back quickly. With these habits, you can evolve your API configuration confidently and keep incidents from small missteps out of your on-call rotation.