Security Basics
8 examples to get you started with Security for Node.js backends - 6 basic and 2 intermediate.
Search across all documentation pages
8 examples to get you started with Security for Node.js backends - 6 basic and 2 intermediate.
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.
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);
}parse throws on invalid data - map to HTTP 400 in your error handler.unknown for body forces validation - never trust req.body typing from middleware alone.schemas/ module.Related: OWASP Top 10 for APIs - API1 broken object level authorization
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.coerce handles string query values from Express/Fastify.limit to prevent DoS via ?limit=999999.Related: Pagination & Filtering - bounded list params
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);parse at boot fails deploys early when secrets are missing..env in the image.env or process.env in startup banners.Related: Configuration Basics - twelve-factor config
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.Related: Security Headers & CORS - credential CORS rules
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");
}user.id === resource.ownerId) on every object access.user; handlers still verify object-level access.Related: Security Rules - baseline HTTP rules
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 });
});err with Pino { err } serializer for stack traces.ZodError to 400 with field paths, not internal messages.Related: Error Response Standards - consistent API errors
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;
}Related: SSRF Guards - full guard implementation
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;
}Object.assign(target, req.body) without key filtering on untrusted input.parse over deep merge for request shaping.lodash versions and avoid _.merge on 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.
Reviewed by Chris St. John·Last updated Jul 16, 2026