Most Node.js APIs get built fast. They get secured… later. This guide makes "later" happen right now — with practical, production-ready patterns for every layer of your API.
A basic Express API (or you'll scaffold one below)
Basic familiarity with REST APIs and async/await
We'll build security in layers, the way real production systems do. Each section is independent — you can apply them to an existing project or follow along from scratch.
Project Setup
If you're starting fresh, scaffold a minimal Express API:
Never commit .env files. Use .env.example (with dummy values and no secrets) as documentation for other developers.
2. Helmet — HTTP Security Headers
HTTP response headers are a free, low-effort layer of defense against a wide class of attacks: XSS, clickjacking, MIME sniffing, and more. Helmet sets secure defaults for all of them.
npm install helmet
import helmet from 'helmet';
app.use(helmet());
That single line sets 14+ security headers. Here's what's happening under the hood:
Header
What it does
Content-Security-Policy
Restricts which scripts, styles, and resources can load
X-Frame-Options
Blocks clickjacking (embedding your app in iframes)
Tip: For pure JSON APIs that never serve HTML, the default CSP is fine as-is. Tighten it if you serve any frontend assets.
3. CORS — Cross-Origin Resource Sharing
By default, browsers block JavaScript from calling your API if it's hosted on a different origin. CORS headers tell the browser which origins are allowed.
Getting CORS wrong is common: too permissive (*) exposes your API to abuse; too restrictive breaks legitimate clients.
npm install cors
import cors from 'cors';
const allowedOrigins = [
'https://yourdomain.com',
'https://app.yourdomain.com',
// Allow localhost in development only
...(process.env.NODE_ENV === 'development'
? ['http://localhost:3000', 'http://localhost:5173']
: []),
];
app.use(
cors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, Postman)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // allow cookies / Authorization headers
maxAge: 86400, // cache preflight for 24 hours
})
);
CORS for route-level overrides
Some endpoints (e.g., public webhooks) may need different CORS rules. Apply cors() as route-level middleware:
The default in-memory store doesn't work correctly when you have multiple server instances (containers, load-balanced nodes). Use Redis:
npm install rate-limit-redis ioredis
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 200,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
}),
});
5. JWT Authentication
JSON Web Tokens are the industry standard for stateless API authentication. Done correctly, they're secure and scalable. Done wrong, they're a liability.
Important: The payload is base64-encoded, not encrypted. Never put sensitive data (passwords, PII, payment info) in a JWT payload. It's readable by anyone who has the token.
✅ Use short-lived access tokens (15 minutes is standard)
✅ Implement refresh token rotation — issue a new refresh token each time one is used
✅ Store refresh token hashes in the database (so you can revoke them)
✅ Explicitly whitelist the alg field — prevent the alg: none exploit
✅ Never store JWTs in localStorage — use httpOnly cookies for web apps
❌ Don't put sensitive data in the payload
❌ Don't use the same secret for access and refresh tokens in high-security systems
6. Input Validation
Never trust user input. Unvalidated input is the root cause of SQL injection, NoSQL injection, path traversal, and a long list of other attacks. Validate at the boundary — before any business logic runs.
npm install zod
Zod provides schema-based validation with full TypeScript inference. It's clean, composable, and errors are developer-friendly.
Defining schemas
// src/schemas/user.schema.js
import { z } from 'zod';
export const registerSchema = z.object({
email: z.string().email().toLowerCase().trim(),
password: z
.string()
.min(12, 'Password must be at least 12 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[0-9]/, 'Must contain a number')
.regex(/[^A-Za-z0-9]/, 'Must contain a special character'),
name: z.string().min(1).max(100).trim(),
});
export const updateProfileSchema = z.object({
name: z.string().min(1).max(100).trim().optional(),
bio: z.string().max(500).trim().optional(),
}).strict(); // reject unknown keys — important!
Here's a complete, production-ready src/index.js incorporating every layer:
// src/index.js
import './config.js'; // ← validates env vars first
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
const app = express();
// ── Security headers ──────────────────────────────────────────
app.use(helmet());
// ── CORS ──────────────────────────────────────────────────────
const allowedOrigins = process.env.NODE_ENV === 'production'
? ['https://yourdomain.com']
: ['http://localhost:3000', 'http://localhost:5173'];
app.use(cors({
origin: (origin, cb) =>
!origin || allowedOrigins.includes(origin)
? cb(null, true)
: cb(new Error('Not allowed by CORS')),
credentials: true,
}));
// ── Body parsing ──────────────────────────────────────────────
app.use(express.json({ limit: '10kb' })); // cap payload size
// ── Global rate limit ─────────────────────────────────────────
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 200,
standardHeaders: 'draft-7',
legacyHeaders: false,
}));
// ── Routes ────────────────────────────────────────────────────
import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';
app.use('/auth', authRoutes);
app.use('/users', userRoutes);
// ── Global error handler ──────────────────────────────────────
app.use((err, req, res, next) => {
const isDev = process.env.NODE_ENV === 'development';
const status = err.status ?? 500;
console.error(err);
res.status(status).json({
error: isDev ? err.message : 'Internal server error',
...(isDev && { stack: err.stack }),
});
});
app.listen(config.port, () =>
console.log(`✅ API running on port ${config.port}`)
);
Security Checklist
Use this before every deployment:
Environment Variables
✅ All secrets in .env (never hardcoded)
✅ .env is in .gitignore
✅ .env.example committed with dummy values
✅ App validates required env vars at startup
HTTP Headers (Helmet)
✅ helmet() applied globally
✅ CSP configured (if serving HTML)
✅ HSTS enabled in production
CORS
✅ Explicit allowlist of origins (no wildcard * in production)
✅ Credentials mode correct for your clients
✅ Methods and headers restricted to what's needed
Rate Limiting
✅ Global limiter on all routes
✅ Strict limiter on auth endpoints
✅ Redis store for multi-instance deployments
JWT
✅ Short-lived access tokens (≤15 min)
✅ Refresh token rotation implemented
✅ Algorithm explicitly whitelisted (no alg: none)
✅ No sensitive data in payload
✅ Tokens stored in httpOnly cookies for web apps
Validation
✅ All request bodies validated with schema
✅ Query params and URL params validated
✅ .strict() used to reject extra fields
✅ Error messages don't leak internal details
General
✅ express.json({ limit: '10kb' }) to cap payloads
✅ Error handler never exposes stack traces in production
✅ Passwords hashed with bcrypt (cost factor ≥ 12)
✅ Constant-time comparisons for secrets (bcrypt handles this)
What's Next
This guide covers the essential security surface of a Node.js API. Once these layers are solid, consider:
Security scanning: npm audit, Snyk, or GitHub Dependabot for dependency vulnerabilities
Logging and monitoring: Structure your logs with Pino and route them to a SIEM. You can't respond to attacks you can't see.
HTTPS everywhere: Terminate TLS at your load balancer or reverse proxy (nginx/Caddy). Never run HTTP in production.
SQL injection prevention: Use parameterized queries or an ORM (Prisma, Drizzle) — never string-concatenate SQL.
Secrets management: For production, graduate from .env files to a secrets manager (AWS Secrets Manager, HashiCorp Vault, Doppler).
Security isn't a checkbox — it's a continuous practice. But a well-layered API with Helmet, rate limiting, validated JWTs, schema validation, and properly scoped CORS is already in better shape than the majority of APIs running in production today.