Security
Node.js powers a huge portion of the public web. Wherever it runs, it handles untrusted input, secrets, session tokens, and user data. A single misconfigured header, missing validation layer, or leaked credential can turn a working service into a breach vector.
This guide covers practical defenses every Node.js backend should implement: Helmet, CORS, input validation with Zod and Joi, secrets management, CSP, rate limiting, TLS, dependency scanning, SSRF, path traversal, injection, and operational controls.
Before choosing controls, understand what you are defending against. Attackers can send arbitrary HTTP requests, intercept traffic, manipulate query parameters, upload malicious files, and exploit vulnerable transitive dependencies.
| Threat | Example | Primary Defense |
|---|---|---|
| Injection | NoSQL query crafted from user input | Input validation + parameterized queries |
| Path traversal | ../../../etc/passwd in a filename | Strict filename validation + sandboxed paths |
| SSRF | Webhook URL points to internal metadata IP | Allow-lists + egress filtering |
| Secret leakage | API key committed to GitHub | Environment variables + secret managers |
| Dependency exploit | Known CVE in transitive package | npm audit + lockfile + update policy |
| MITM | Credentials sent over plaintext HTTP | TLS + HSTS + secure cookies |
Helmet sets HTTP headers with safe defaults and mitigates XSS, clickjacking, MIME sniffing, and protocol downgrade attacks. It works with Express and any Connect-compatible framework.
| 1 | import express from 'express'; |
| 2 | import helmet from 'helmet'; |
| 3 | |
| 4 | const app = express(); |
| 5 | app.use(helmet()); |
| 6 | |
| 7 | app.get('/health', (req, res) => res.json({ status: 'ok' })); |
| 8 | app.listen(3000); |
| 1 | app.use( |
| 2 | helmet({ |
| 3 | contentSecurityPolicy: { |
| 4 | directives: { |
| 5 | defaultSrc: ["'self'"], |
| 6 | scriptSrc: ["'self'", "'nonce-{{nonce}}'"], |
| 7 | styleSrc: ["'self'", "'unsafe-inline'"], |
| 8 | imgSrc: ["'self'", 'data:', 'https:'], |
| 9 | connectSrc: ["'self'"], |
| 10 | objectSrc: ["'none'"], |
| 11 | upgradeInsecureRequests: [], |
| 12 | }, |
| 13 | }, |
| 14 | hsts: { |
| 15 | maxAge: 31536000, |
| 16 | includeSubDomains: true, |
| 17 | preload: true, |
| 18 | }, |
| 19 | referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, |
| 20 | }) |
| 21 | ); |
best practice
CORS controls which origins can request resources from your API. A permissive origin: '*' combined with credentials allows any website to make authenticated requests on behalf of logged-in users.
| 1 | // DANGEROUS: never use this in production |
| 2 | app.use(cors({ origin: '*', credentials: true })); |
| 3 | |
| 4 | // SAFE: allow-list with credentials support |
| 5 | const allowedOrigins = new Set([ |
| 6 | 'https://app.forgelearn.dev', |
| 7 | 'https://admin.forgelearn.dev', |
| 8 | ]); |
| 9 | |
| 10 | app.use( |
| 11 | cors({ |
| 12 | origin: (origin, callback) => { |
| 13 | if (!origin || allowedOrigins.has(origin)) { |
| 14 | callback(null, true); |
| 15 | } else { |
| 16 | callback(new Error('Not allowed by CORS')); |
| 17 | } |
| 18 | }, |
| 19 | credentials: true, |
| 20 | methods: ['GET', 'POST', 'PUT', 'DELETE'], |
| 21 | allowedHeaders: ['Content-Type', 'Authorization'], |
| 22 | }) |
| 23 | ); |
warning
Most injection and logic bugs start with unvalidated input. Zod is type-safe and works well with TypeScript. Joi is battle-tested and widely used in Express ecosystems.
| 1 | import { z } from 'zod'; |
| 2 | |
| 3 | const UserSchema = z.object({ |
| 4 | email: z.string().email().max(320).toLowerCase(), |
| 5 | age: z.number().int().min(0).max(150), |
| 6 | role: z.enum(['user', 'admin']).default('user'), |
| 7 | tags: z.array(z.string().min(1).max(50)).max(10), |
| 8 | }); |
| 9 | |
| 10 | function createUser(req, res, next) { |
| 11 | const parsed = UserSchema.safeParse(req.body); |
| 12 | if (!parsed.success) { |
| 13 | return res.status(400).json({ |
| 14 | error: 'Validation failed', |
| 15 | issues: parsed.error.issues, |
| 16 | }); |
| 17 | } |
| 18 | req.validatedBody = parsed.data; |
| 19 | next(); |
| 20 | } |
| 1 | import Joi from 'joi'; |
| 2 | |
| 3 | const querySchema = Joi.object({ |
| 4 | page: Joi.number().integer().min(1).max(10000).default(1), |
| 5 | limit: Joi.number().integer().valid(10, 25, 50, 100).default(25), |
| 6 | search: Joi.string().trim().max(200).allow(''), |
| 7 | sort: Joi.string().pattern(/^[a-z_]+$/).default('created_at'), |
| 8 | }); |
| 9 | |
| 10 | async function listUsers(req, res, next) { |
| 11 | const { error, value } = querySchema.validate(req.query, { |
| 12 | abortEarly: false, |
| 13 | stripUnknown: true, |
| 14 | }); |
| 15 | |
| 16 | if (error) { |
| 17 | return res.status(400).json({ |
| 18 | error: 'Invalid query parameters', |
| 19 | details: error.details.map((d) => d.message), |
| 20 | }); |
| 21 | } |
| 22 | |
| 23 | req.validatedQuery = value; |
| 24 | next(); |
| 25 | } |
best practice
Secrets include database passwords, API keys, signing tokens, and TLS private keys. Node.js loads environment variables into process.env, but the surrounding discipline matters as much as the mechanism.
| 1 | # .env (never commit this file) |
| 2 | DATABASE_URL=postgresql://user:password@db.local:5432/app |
| 3 | JWT_SECRET=$(openssl rand -base64 32) |
| 4 | STRIPE_SECRET_KEY=sk_live_... |
| 5 | |
| 6 | # .env.example (safe to commit) |
| 7 | DATABASE_URL= |
| 8 | JWT_SECRET= |
| 9 | STRIPE_SECRET_KEY= |
| 10 | PORT=3000 |
| 1 | import { z } from 'zod'; |
| 2 | |
| 3 | const EnvSchema = z.object({ |
| 4 | NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), |
| 5 | PORT: z.coerce.number().default(3000), |
| 6 | DATABASE_URL: z.string().startsWith('postgresql://').min(10), |
| 7 | JWT_SECRET: z.string().min(32), |
| 8 | STRIPE_SECRET_KEY: z.string().startsWith('sk_'), |
| 9 | }); |
| 10 | |
| 11 | const env = EnvSchema.parse(process.env); |
| 12 | export default env; |
danger
Rate limiting protects your API from brute-force attacks, scraping, and traffic spikes. Production deployments should use Redis so limits are shared across all instances behind a load balancer.
| 1 | import rateLimit from 'express-rate-limit'; |
| 2 | import RedisStore from 'rate-limit-redis'; |
| 3 | import { createClient } from 'redis'; |
| 4 | |
| 5 | const redisClient = createClient({ url: process.env.REDIS_URL }); |
| 6 | await redisClient.connect(); |
| 7 | |
| 8 | const limiter = rateLimit({ |
| 9 | windowMs: 15 * 60 * 1000, |
| 10 | max: 100, |
| 11 | standardHeaders: true, |
| 12 | legacyHeaders: false, |
| 13 | store: new RedisStore({ |
| 14 | sendCommand: (...args) => redisClient.sendCommand(args), |
| 15 | }), |
| 16 | keyGenerator: (req) => req.ip, |
| 17 | handler: (req, res) => { |
| 18 | res.status(429).json({ error: 'Too many requests, please slow down.' }); |
| 19 | }, |
| 20 | }); |
| 21 | |
| 22 | app.use('/api/', limiter); |
| Endpoint Type | Window | Max Requests |
|---|---|---|
| General API | 15 minutes | 100-1000 |
| Authentication | 15 minutes | 5-10 |
| Password reset | 1 hour | 3 |
warning
TLS encrypts data in transit and prevents man-in-the-middle attacks. In production, termination usually happens at a reverse proxy or load balancer. Your Node.js app must still reject plaintext traffic and trust only the proxy in front of it.
| 1 | import https from 'node:https'; |
| 2 | import fs from 'node:fs'; |
| 3 | import express from 'express'; |
| 4 | |
| 5 | const app = express(); |
| 6 | app.set('trust proxy', 1); |
| 7 | |
| 8 | const server = https.createServer( |
| 9 | { |
| 10 | key: fs.readFileSync('/etc/ssl/private/server.key'), |
| 11 | cert: fs.readFileSync('/etc/ssl/certs/server.crt'), |
| 12 | minVersion: 'TLSv1.2', |
| 13 | cipher: 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256', |
| 14 | }, |
| 15 | app |
| 16 | ); |
| 17 | |
| 18 | server.listen(443); |
| 1 | app.use((req, res, next) => { |
| 2 | if (req.secure || req.headers['x-forwarded-proto'] === 'https') { |
| 3 | return next(); |
| 4 | } |
| 5 | res.redirect(301, `https://${req.headers.host}${req.url}`); |
| 6 | }); |
best practice
Every dependency is potential attack surface. Transitive packages can introduce malicious code, and even popular packages accumulate CVEs. Treat dependency updates as a security process.
| 1 | # Audit installed packages |
| 2 | npm audit |
| 3 | |
| 4 | # Audit with JSON output for CI |
| 5 | npm audit --json |
| 6 | |
| 7 | # Fix automatically where possible |
| 8 | npm audit fix |
| 9 | |
| 10 | # Override a vulnerable transitive dependency |
| 11 | npm pkg set overrides.left-pad@^1.0.0=1.3.0 |
Commit package-lock.json and enforce reproducible installs in CI with npm ci. This prevents fresh installs from silently pulling newer, potentially compromised versions. Review lockfile changes in pull requests.
info
SSRF occurs when your server makes HTTP requests to attacker-controlled URLs. This can expose internal metadata services, cloud APIs, or database administration panels. Webhooks, URL previews, and file imports are common vectors.
| 1 | import { URL } from 'node:url'; |
| 2 | |
| 3 | const BLOCKED_HOSTS = new Set([ |
| 4 | 'localhost', '127.0.0.1', '0.0.0.0', |
| 5 | '169.254.169.254', '[::1]', |
| 6 | ]); |
| 7 | const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']); |
| 8 | |
| 9 | function isUrlAllowed(rawUrl) { |
| 10 | let parsed; |
| 11 | try { |
| 12 | parsed = new URL(rawUrl); |
| 13 | } catch { |
| 14 | return false; |
| 15 | } |
| 16 | if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) return false; |
| 17 | if (BLOCKED_HOSTS.has(parsed.hostname.toLowerCase())) return false; |
| 18 | |
| 19 | const ipMatch = parsed.hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); |
| 20 | if (ipMatch) { |
| 21 | const [_, a, b] = ipMatch.map(Number); |
| 22 | if (a === 10) return false; |
| 23 | if (a === 172 && b >= 16 && b <= 31) return false; |
| 24 | if (a === 192 && b === 168) return false; |
| 25 | } |
| 26 | return true; |
| 27 | } |
| 28 | |
| 29 | app.post('/webhook', async (req, res) => { |
| 30 | const url = req.body.url; |
| 31 | if (!isUrlAllowed(url)) { |
| 32 | return res.status(400).json({ error: 'URL not allowed' }); |
| 33 | } |
| 34 | const response = await fetch(url, { method: 'POST', body: req.body.payload }); |
| 35 | res.json({ ok: response.ok }); |
| 36 | }); |
warning
Path traversal happens when user input constructs file paths without validation. Sequences such as ../ can escape the intended directory and expose sensitive files. Static file servers and download endpoints are common targets.
| 1 | import path from 'node:path'; |
| 2 | import fs from 'node:fs/promises'; |
| 3 | |
| 4 | const PUBLIC_DIR = '/var/www/uploads'; |
| 5 | |
| 6 | app.get('/download/:filename', async (req, res) => { |
| 7 | const requested = req.params.filename; |
| 8 | |
| 9 | if (!/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9]+$/.test(requested)) { |
| 10 | return res.status(400).json({ error: 'Invalid filename' }); |
| 11 | } |
| 12 | |
| 13 | const resolved = path.resolve(PUBLIC_DIR, requested); |
| 14 | if (!resolved.startsWith(PUBLIC_DIR + path.sep)) { |
| 15 | return res.status(403).json({ error: 'Forbidden' }); |
| 16 | } |
| 17 | |
| 18 | try { |
| 19 | const content = await fs.readFile(resolved); |
| 20 | res.setHeader('Content-Disposition', `attachment; filename="${path.basename(resolved)}"`); |
| 21 | res.send(content); |
| 22 | } catch (err) { |
| 23 | if (err.code === 'ENOENT') return res.status(404).json({ error: 'Not found' }); |
| 24 | throw err; |
| 25 | } |
| 26 | }); |
best practice
Injection attacks include SQL injection, NoSQL injection, command injection, and template injection. The root cause is always the same: untrusted data is interpreted as code. The defense is consistent: separate data from commands using parameterized APIs.
| 1 | // DANGEROUS: never concatenate user input into SQL |
| 2 | const query = `SELECT * FROM users WHERE email = '${email}'`; |
| 3 | |
| 4 | // SAFE: use parameterized queries |
| 5 | import pg from 'pg'; |
| 6 | const { Pool } = pg; |
| 7 | const pool = new Pool({ connectionString: process.env.DATABASE_URL }); |
| 8 | |
| 9 | const result = await pool.query( |
| 10 | 'SELECT * FROM users WHERE email = $1', |
| 11 | [email] |
| 12 | ); |
| 1 | import { z } from 'zod'; |
| 2 | |
| 3 | const UserQuerySchema = z.object({ |
| 4 | email: z.string().email(), |
| 5 | status: z.enum(['active', 'inactive']).optional(), |
| 6 | }); |
| 7 | |
| 8 | async function findUser(query) { |
| 9 | const parsed = UserQuerySchema.parse(query); |
| 10 | const filter = { email: parsed.email }; |
| 11 | if (parsed.status) filter.status = parsed.status; |
| 12 | return db.collection('users').findOne(filter); |
| 13 | } |
danger
Logs are essential for incident response, but they can become a liability if they capture sensitive data. Design logging to record enough context for debugging while excluding passwords, tokens, payment details, and PII.
| 1 | const sensitiveKeys = new Set([ |
| 2 | 'password', 'token', 'secret', 'authorization', 'cookie' |
| 3 | ]); |
| 4 | |
| 5 | function redact(info) { |
| 6 | if (typeof info !== 'object' || info === null) return info; |
| 7 | for (const key of Object.keys(info)) { |
| 8 | if (sensitiveKeys.has(key.toLowerCase())) { |
| 9 | info[key] = '[REDACTED]'; |
| 10 | } |
| 11 | } |
| 12 | return info; |
| 13 | } |
| 14 | |
| 15 | app.use((err, req, res, next) => { |
| 16 | const correlationId = req.headers['x-request-id'] || crypto.randomUUID(); |
| 17 | logger.error(redact({ |
| 18 | message: err.message, |
| 19 | stack: err.stack, |
| 20 | correlationId, |
| 21 | path: req.path, |
| 22 | method: req.method, |
| 23 | })); |
| 24 | |
| 25 | res.status(err.status || 500).json({ |
| 26 | error: 'Internal server error', |
| 27 | correlationId, |
| 28 | }); |
| 29 | }); |
info