<< Home

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.

💡 Keep a one page threat model in your repo so reviewers can reference it during code review. Update it when you add new external integrations.

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
  });
}
⚠️ Cookies should be HttpOnly, Secure, and SameSite=Lax or Strict unless you have a very specific reason to relax those flags.

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.

OptionProsConsUse when
.env filesSimple; works locallyEasy to leak; hard to rotateLocal dev only
Environment varsStandard; easy to injectProcess wide; appears in dumpsSmall apps and staging
Cloud secret managerAudit; rotation; IAMService couplingProduction services
HSM / KMSStrong keys; envelopesCost; complexityHigh 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
💡 Use a private registry mirror or proxy that enforces signature verification and caches vetted tarballs.

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.

AlgorithmBehaviorGood for
Fixed windowCount per intervalSimple endpoints
Sliding windowWeight recent hitsPublic APIs
Token bucketBudget with refillBursty clients
Leaky bucketConstant outflowQueues 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.

⚠️ Avoid returning different error messages for “email not found” and “wrong password.” A single generic message reduces account enumeration.

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;
    }
  }
}
💡 Pair circuit breakers with synthetic health checks that exercise a cheap, realistic code path so your breaker decisions reflect true service health.

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.