Chapter 18: Security and Reliability
Security and reliability are shared responsibilities between your code, your platform, and your operations. This chapter gives you a practical baseline for real projects that run on the open internet. You will define a clear threat model, validate every input, authenticate users correctly, protect secrets, keep transport secure, keep dependencies healthy, slow bad actors, and build in resilience so that partial failure does not become total failure.
Threat model, input validation, and auth
You cannot defend what you have not defined. A small, written threat model helps you decide which controls matter now. From there, validate inputs at every boundary and authenticate callers in a way that suits your application shape and risk profile.
Define assets, actors, and trust boundaries
List sensitive assets such as account data, payment intents, and API keys. List likely actors such as regular users, curious tinkerers, automated scrapers, and targeted attackers. Draw the trust boundaries between browser, API, internal services, and databases. Any crossing of a boundary needs verification and logging.
Input validation at the boundary
Validate all external inputs before they reach business logic. Treat query strings, path params, JSON bodies, headers, cookies, and environment variables as untrusted. Prefer declarative schemas so you can parse and transform in one pass.
// zod example for a login payload
import { z } from "zod";
const LoginSchema = z.object({
email: z.string().email().min(6).max(254),
password: z.string().min(8).max(128),
remember: z.boolean().optional()
});
type LoginInput = z.infer<typeof LoginSchema>;
// Express handler using safe parse
app.post("/api/login", async (req, res) => {
const parsed = LoginSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "Invalid input", details: parsed.error.flatten() });
}
const input: LoginInput = parsed.data;
// proceed with auth …
});
Output encoding and query safety
Encode outputs for the context they will be rendered in such as HTML, attributes, URLs, or JSON. Use parameterized queries for databases and safe templating for views. Never build SQL by concatenating strings. The same principle applies to shell commands and file paths.
Authentication choices and session scope
Pick an approach that fits your topology. Traditional web apps often use server sessions plus cookies. Distributed APIs often use structured tokens. Scope tokens narrowly, include expiry, and prefer revocation levers.
// Verifying a JWT with audience and clock tolerance
import jwt from "jsonwebtoken";
const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY!;
function verifyAccessToken(token: string) {
return jwt.verify(token, PUBLIC_KEY, {
algorithms: ["RS256"],
audience: "api",
issuer: "https://auth.example.com",
clockTolerance: 5
});
}
Password storage and second factors
Never store raw passwords. Use a memory hard password hasher such as Argon2id with strong parameters. Add optional second factors such as TOTP or WebAuthn for higher value actions.
// Argon2id example with reasonable defaults
import argon2 from "argon2";
export async function hashPassword(pw: string) {
return argon2.hash(pw, { type: argon2.argon2id, timeCost: 3, memoryCost: 19456, parallelism: 1 });
}
export async function verifyPassword(hash: string, pw: string) {
return argon2.verify(hash, pw);
}
Secrets, TLS, and dependency hygiene
Secrets deserve careful handling at rest and in transit. Transport protection keeps data confidential and authentic between parties. Healthy dependencies reduce your exposure to upstream risk.
Secret storage options and trade offs
Choose storage that fits your deployment maturity. Keep a single source of truth and automate rotation wherever possible.
| Option | Pros | Cons | Use when |
| .env files | Simple; works locally | Easy to leak; hard to rotate | Local dev only |
| Environment vars | Standard; easy to inject | Process wide; appears in dumps | Small apps and staging |
| Cloud secret manager | Audit; rotation; IAM | Service coupling | Production services |
| HSM / KMS | Strong keys; envelopes | Cost; complexity | High assurance systems |
// Fetching a secret at runtime with a placeholder client
const apiKey = await secretClient.get("payments/apiKey"); // resolves to latest version …
Environment variables and process.env
Load configuration from the environment at process start then validate with a schema. Avoid accessing process.env deep inside business logic. Pass typed config down through constructors so code is testable.
// Validate config once at boot
import { z } from "zod";
const ConfigSchema = z.object({
NODE_ENV: z.enum(["development","test","production"]),
PORT: z.coerce.number().int().positive().default(3000),
JWT_PUBLIC_KEY: z.string().min(1),
DATABASE_URL: z.string().url()
});
export const CONFIG = ConfigSchema.parse(process.env);
TLS everywhere
Use HTTPS between client and edge, and between edge and origin whenever possible. Prefer modern TLS settings and short certificates. Terminate at the load balancer or ingress then re encrypt to services that cross host boundaries.
// Minimal HTTPS server for internal admin endpoints
import fs from "node:fs";
import https from "node:https";
import app from "./app.js";
const server = https.createServer({
key: fs.readFileSync("./tls/key.pem"),
cert: fs.readFileSync("./tls/cert.pem")
}, app);
server.listen(8443);
Dependency hygiene and supply chain care
Pin versions with a lockfile, update on a schedule, and scan continuously. Treat install hooks and transitive dependencies as part of your attack surface. Prefer small, well maintained packages over large bundles that you barely use.
// Common tasks you can automate in CI
# verify integrity and engines
npm ci
# look for known issues
npm audit --production
# check for orphaned or extraneous modules
npm ls --all
Rate limiting and abuse prevention
Abuse controls make your service predictable under pressure. You will limit how often expensive actions can run, detect patterns across identities, and slow down automated attacks without punishing legitimate users.
Choosing a limiting strategy
Pick a windowing algorithm that fits your needs. Fixed windows are simple; sliding windows reduce burstiness; token buckets allow controlled bursts; leaky buckets smooth output. Use keys that reflect risk, such as IP plus user ID for authenticated traffic.
| Algorithm | Behavior | Good for |
| Fixed window | Count per interval | Simple endpoints |
| Sliding window | Weight recent hits | Public APIs |
| Token bucket | Budget with refill | Bursty clients |
| Leaky bucket | Constant outflow | Queues and workers |
Implementing a Redis token bucket
Centralized stores allow consistent enforcement across pods. Keep the operation atomic to prevent race conditions and include metadata for observability. Use TTLs so idle keys disappear.
// Pseudo implementation for a Redis token bucket
// KEYS[1] = bucket key, ARGV = capacity, refillPerSec, now, cost
// Returns remaining tokens and reset time …
const lua = `
-- token bucket logic here …
`;
const [remaining, reset] = await redis.eval(lua, { keys: ["rl:u:"+userId], arguments: ["100","10",Date.now().toString(),"1"] });
if (remaining < 0) {
res.setHeader("Retry-After", Math.ceil((reset - Date.now()) / 1000));
return res.status(429).json({ error: "Too many requests" });
}
Login hardening and automated abuse
Use progressive delays, sliding limits, and device binding for authentication flows. Consider challenges for anonymous high risk endpoints. Log decisions with opaque identifiers so you can debug without leaking private data.
Resource quotas and fairness
For multi tenant systems, set per tenant quotas for requests, concurrent jobs, storage, and bandwidth. Enforce at admission time so you can reject early and cheap. Expose headers like X-RateLimit-Remaining and Retry-After to help well behaved clients adapt.
Resilience patterns and backpressure
Reliability assumes that things fail. Add timeouts, retries with jitter, isolation between components, and pressure relief valves so your system stays within safe limits during incidents.
Timeouts and bounded work
Every network call needs a timeout. Timeouts must be smaller than the caller’s timeout so failures propagate predictably. Bound CPU heavy tasks with worker pools and queue long running jobs.
// Fetch with AbortController and a hard timeout
import fetch from "node-fetch";
async function getJson(url: string, ms = 2000) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) throw new Error("Bad status "+res.status);
return await res.json();
} finally {
clearTimeout(t);
}
}
Retries with jitter
Use a small number of retries for transient errors only. Add full jitter to avoid thundering herds. Never retry non idempotent actions unless you have an idempotency key.
// Exponential backoff with full jitter
function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); }
async function withRetries(fn: () => Promise<unknown>) {
const base = 100;
for (let i = 0; i < 4; i++) {
try { return await fn(); }
catch (e) {
const wait = Math.floor(Math.random() * (base * (2 ** i)));
await sleep(wait);
}
}
throw new Error("Exhausted retries");
}
Bulkheads, pools, and isolation
Separate resources so one noisy neighbor cannot sink the ship. Use connection pools per dependency, separate thread pools for blocking work, and independent queues for different priorities. Apply admission control at each bulkhead.
Circuit breakers and graceful degradation
Fail fast when a dependency is unhealthy. A circuit breaker tracks recent failures and opens the circuit to reject calls quickly, then probes occasionally to see if the dependency has recovered. Provide a fallback when possible such as a cached value or a reduced feature.
// Tiny circuit breaker sketch
type State = "closed" | "open" | "half";
class Breaker {
state: State = "closed";
failures = 0; openedAt = 0;
constructor(private threshold = 5, private coolMs = 5000) {}
async exec<T>(fn: () => Promise<T>): Promise<T> {
const now = Date.now();
if (this.state === "open" && now - this.openedAt > this.coolMs) this.state = "half";
if (this.state === "open") throw new Error("Circuit open");
try {
const res = await fn();
this.failures = 0; this.state = "closed";
return res;
} catch (e) {
this.failures++;
if (this.failures >= this.threshold) { this.state = "open"; this.openedAt = now; }
throw e;
}
}
}
Backpressure in streams
Use Node streams to avoid loading entire payloads into memory. Respect backpressure by awaiting write() when it returns false and by tuning highWaterMark. Propagate pressure across hops so a slow downstream slows the upstream naturally.
// Properly awaiting drain when respecting backpressure
import { createReadStream, createWriteStream } from "node:fs";
const src = createReadStream("big.json", { highWaterMark: 64 * 1024 });
const dst = createWriteStream("copy.json", { highWaterMark: 64 * 1024 });
src.on("data", chunk => {
if (!dst.write(chunk)) {
src.pause();
dst.once("drain", () => src.resume());
}
});
src.on("end", () => dst.end());
Load shedding and queues
When you cannot keep up, shed load deliberately. Reject low priority work first, queue work that can be deferred, and cap queue length so latency does not grow without bound. Expose saturation metrics so autoscaling can react intelligently.
With a clear model, strong boundaries, careful secret handling, healthy dependencies, fair usage controls, and resilience patterns, your service remains safe and steady even when conditions are rough.