|$ curl https://forge-ai.dev/api/markdown?path=docs/nodejs/security
$cat docs/security.md
updated Recently·20-28 min read·published

Security

Node.jsIntermediate to Advanced🎯Free Tools
Introduction

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.

Threat Model

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.

ThreatExamplePrimary Defense
InjectionNoSQL query crafted from user inputInput validation + parameterized queries
Path traversal../../../etc/passwd in a filenameStrict filename validation + sandboxed paths
SSRFWebhook URL points to internal metadata IPAllow-lists + egress filtering
Secret leakageAPI key committed to GitHubEnvironment variables + secret managers
Dependency exploitKnown CVE in transitive packagenpm audit + lockfile + update policy
MITMCredentials sent over plaintext HTTPTLS + HSTS + secure cookies
Secure Headers with Helmet

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.

helmet-basic.mjs
JavaScript
1import express from 'express';
2import helmet from 'helmet';
3
4const app = express();
5app.use(helmet());
6
7app.get('/health', (req, res) => res.json({ status: 'ok' }));
8app.listen(3000);
helmet-config.mjs
JavaScript
1app.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

Never disable Helmet features globally without understanding why. If crossOriginEmbedderPolicy breaks an embedded widget, disable only that directive and document the exception.
Configuring CORS Correctly

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.

cors-config.mjs
JavaScript
1// DANGEROUS: never use this in production
2app.use(cors({ origin: '*', credentials: true }));
3
4// SAFE: allow-list with credentials support
5const allowedOrigins = new Set([
6 'https://app.forgelearn.dev',
7 'https://admin.forgelearn.dev',
8]);
9
10app.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

CORS is a browser enforcement mechanism, not server-side access control. Always perform authorization checks on the server regardless of origin. Attackers can bypass CORS by calling your API from a server.
Input Validation with Zod and Joi

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.

zod-validation.mjs
JavaScript
1import { z } from 'zod';
2
3const 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
10function 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}
joi-validation.mjs
JavaScript
1import Joi from 'joi';
2
3const 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
10async 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

Validate at the boundary. Parse bodies, query strings, path parameters, headers, and file metadata before they reach business logic. Use stripUnknown or strict() to reject extra fields.
Secrets and Environment Variables

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.

env-files.env
Bash
1# .env (never commit this file)
2DATABASE_URL=postgresql://user:password@db.local:5432/app
3JWT_SECRET=$(openssl rand -base64 32)
4STRIPE_SECRET_KEY=sk_live_...
5
6# .env.example (safe to commit)
7DATABASE_URL=
8JWT_SECRET=
9STRIPE_SECRET_KEY=
10PORT=3000
env-validation.mjs
JavaScript
1import { z } from 'zod';
2
3const 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
11const env = EnvSchema.parse(process.env);
12export default env;

danger

Never log process.env, request headers containing tokens, or database connection strings. Many breaches begin with a single log line containing a secret shipped to an aggregation service.
Rate Limiting and Abuse Prevention

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.

rate-limit-redis.mjs
JavaScript
1import rateLimit from 'express-rate-limit';
2import RedisStore from 'rate-limit-redis';
3import { createClient } from 'redis';
4
5const redisClient = createClient({ url: process.env.REDIS_URL });
6await redisClient.connect();
7
8const 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
22app.use('/api/', limiter);
Endpoint TypeWindowMax Requests
General API15 minutes100-1000
Authentication15 minutes5-10
Password reset1 hour3

warning

Do not rely solely on IP-based rate limiting. Shared NATs, mobile carriers, and corporate proxies can concentrate thousands of users behind a single IP. Supplement with user-ID or session-based limits.
HTTPS and TLS Configuration

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.

https-server.mjs
JavaScript
1import https from 'node:https';
2import fs from 'node:fs';
3import express from 'express';
4
5const app = express();
6app.set('trust proxy', 1);
7
8const 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
18server.listen(443);
https-redirect.mjs
JavaScript
1app.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

Use TLS certificate automation via Let's Encrypt, cert-manager, or your cloud provider. Short-lived certificates reduce the blast radius of a compromised key. Monitor expiration dates and alert at least 30 days before renewal.
Dependency Scanning and Supply Chain

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.

npm-audit.sh
Bash
1# Audit installed packages
2npm audit
3
4# Audit with JSON output for CI
5npm audit --json
6
7# Fix automatically where possible
8npm audit fix
9
10# Override a vulnerable transitive dependency
11npm 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

Consider running npm config set ignore-scripts true globally and only enabling scripts for packages you trust. Many supply-chain attacks rely on postinstall hooks.
Server-Side Request Forgery (SSRF)

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.

ssrf-defense.mjs
JavaScript
1import { URL } from 'node:url';
2
3const BLOCKED_HOSTS = new Set([
4 'localhost', '127.0.0.1', '0.0.0.0',
5 '169.254.169.254', '[::1]',
6]);
7const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
8
9function 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
29app.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

DNS rebinding can bypass hostname checks because the attacker controls both DNS and the target server. Resolve the hostname, validate the IP, and then make the request to the validated IP with a Host header. Better yet, route outbound requests through a dedicated egress gateway.
Path Traversal and File Handling

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.

path-traversal-defense.mjs
JavaScript
1import path from 'node:path';
2import fs from 'node:fs/promises';
3
4const PUBLIC_DIR = '/var/www/uploads';
5
6app.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

Store user-uploaded files outside the web root and serve them through a controller that enforces authorization. Direct access to upload directories bypasses authentication and can turn a file upload feature into remote code execution.
Injection Attacks

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.

sql-injection.mjs
JavaScript
1// DANGEROUS: never concatenate user input into SQL
2const query = `SELECT * FROM users WHERE email = '${email}'`;
3
4// SAFE: use parameterized queries
5import pg from 'pg';
6const { Pool } = pg;
7const pool = new Pool({ connectionString: process.env.DATABASE_URL });
8
9const result = await pool.query(
10 'SELECT * FROM users WHERE email = $1',
11 [email]
12);
nosql-injection.mjs
JavaScript
1import { z } from 'zod';
2
3const UserQuerySchema = z.object({
4 email: z.string().email(),
5 status: z.enum(['active', 'inactive']).optional(),
6});
7
8async 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

Avoid executing shell commands with user input. If you must spawn a process, pass arguments as an array so the OS treats them as data rather than shell syntax. spawn('grep', [pattern, file]) is safe; exec(`grep ${pattern} ${file}`) is not.
Logging and Error Handling

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.

error-handler.mjs
JavaScript
1const sensitiveKeys = new Set([
2 'password', 'token', 'secret', 'authorization', 'cookie'
3]);
4
5function 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
15app.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

Centralize error handling at the framework level. Never use try-catch blocks that silently swallow errors or return detailed messages. A consistent error shape reduces information leakage and makes monitoring easier.
Production Security Checklist
Headers: Enable Helmet, configure CSP, set HSTS, redirect HTTP to HTTPS
Input: Validate every body, query, param, and header; use parameterized queries
Files: Sanitize filenames, block path traversal, store uploads outside web root
Access: Enforce CORS allow-lists, rate limiting, and server-side authorization
Secrets: Load from env or secret manager; never log secrets; rotate credentials
Dependencies: Pin versions, use npm ci, audit regularly, review lockfile diffs
$Blueprint — Engineering Documentation·Section ID: NODE-SEC-01·Revision: 1.0