Security Basics
8 examples to get you started with Security for Node.js backends - 6 basic and 2 intermediate.
Prerequisites
npm install zod express@5 helmet
npm install -D typescript@5.6 tsxNode.js services face untrusted input from HTTP bodies, query strings, headers, uploaded files, webhooks, and environment variables loaded at boot.
Basic Examples
1. Validate Request Bodies with Zod
Reject malformed input before it reaches business logic.
import { z } from "zod";
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(120),
});
type CreateUser = z.infer<typeof CreateUserSchema>;
function parseCreateUser(body: unknown): CreateUser {
return CreateUserSchema.parse(body);
}parsethrows on invalid data - map to HTTP 400 in your error handler.unknownforbodyforces validation - never trustreq.bodytyping from middleware alone.- Keep schemas next to route definitions or in a shared
schemas/module.
Related: OWASP Top 10 for APIs - API1 broken object level authorization
2. Never Trust Query Parameters
IDs and filters from the URL are attacker-controlled.
import { z } from "zod";
const ListQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
function parseListQuery(query: Record<string, unknown>) {
return ListQuerySchema.parse(query);
}z.coercehandles string query values from Express/Fastify.- Cap
limitto prevent DoS via?limit=999999. - Authorization must still verify the caller can access the returned rows.
Related: Pagination & Filtering - bounded list params
3. Load Secrets From Environment, Not Code
API keys in git history are forever.
import { z } from "zod";
const EnvSchema = z.object({
JWT_SECRET: z.string().min(32),
DATABASE_URL: z.string().url(),
});
export const env = EnvSchema.parse(process.env);parseat boot fails deploys early when secrets are missing.- Production secrets come from vault/SSM/k8s Secrets, not
.envin the image. - Never log
envorprocess.envin startup banners.
Related: Configuration Basics - twelve-factor config
4. Set Security Headers with Helmet
Default headers reduce XSS, clickjacking, and MIME sniffing risk.
import express from "express";
import helmet from "helmet";
const app = express();
app.use(helmet());
app.disable("x-powered-by");helmet()sets sensible defaults for common headers.disable("x-powered-by")removes framework fingerprinting.- Tune CSP per frontend needs - strict defaults may break inline scripts.
Related: Security Headers & CORS - credential CORS rules
5. Authenticate Before Authorize
Identity and permission checks are separate steps.
type AuthUser = { id: string; role: "admin" | "member" };
function requireAuth(user: AuthUser | undefined): AuthUser {
if (!user) throw new HttpError(401, "Unauthorized");
return user;
}
function requireAdmin(user: AuthUser): void {
if (user.role !== "admin") throw new HttpError(403, "Forbidden");
}- 401 = not authenticated; 403 = authenticated but not allowed.
- Check resource ownership (
user.id === resource.ownerId) on every object access. - Middleware can attach
user; handlers still verify object-level access.
Related: Security Rules - baseline HTTP rules
6. Sanitize Outbound Errors
Stack traces belong in logs, not JSON responses.
import express from "express";
import pino from "pino";
class HttpError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
const logger = pino({ level: "info" });
const app = express();
app.use((err: unknown, req: express.Request, res: express.Response, _next: express.NextFunction) => {
const status = err instanceof HttpError ? err.status : 500;
const message = status < 500 ? (err as Error).message : "Internal Server Error";
logger.error({ err, path: req.path }, "request_error");
res.status(status).json({ error: message });
});- 5xx responses use generic messages - details aid attackers.
- Log full
errwith Pino{ err }serializer for stack traces. - Map Zod
ZodErrorto 400 with field paths, not internal messages.
Related: Error Response Standards - consistent API errors
Intermediate Examples
7. Block SSRF on User-Supplied URLs
Fetching arbitrary URLs enables access to internal metadata services.
import { lookup } from "node:dns/promises";
import ipaddr from "ipaddr.js";
async function assertPublicUrl(raw: string): Promise<URL> {
const url = new URL(raw);
if (!["http:", "https:"].includes(url.protocol)) throw new Error("Invalid protocol");
const { address } = await lookup(url.hostname);
const parsed = ipaddr.parse(address);
const range = parsed.range();
if (range !== "unicast") throw new Error("Blocked IP range");
return url;
}- Resolve DNS before fetch - attackers use DNS rebinding if you check hostname only.
- Block private, loopback, and link-local ranges.
- Allowlist domains when the integration set is known (webhook partners).
Related: SSRF Guards - full guard implementation
8. Safe Object Merge Without Prototype Pollution
Deep merge from untrusted JSON can poison Object.prototype.
function safeAssign<T extends Record<string, unknown>>(
target: T,
source: unknown
): T {
if (source === null || typeof source !== "object" || Array.isArray(source)) {
throw new Error("Invalid merge source");
}
for (const key of Object.keys(source as object)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
(target as Record<string, unknown>)[key] = (source as Record<string, unknown>)[key];
}
return target;
}- Never
Object.assign(target, req.body)without key filtering on untrusted input. - Prefer Zod
parseover deep merge for request shaping. - Pin
lodashversions and avoid_.mergeon user JSON.
Related: Prototype Pollution - lodash and merge risks
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.