Skip to content

Cybersecurity Engineering Handbook / Chapter 22

Secure Coding Foundations

Trace hostile input through validation, authorization, side effects, errors, and evidence, then prove the path with negative tests.

A profile endpoint accepts a user identifier in the URL and a JSON object in the body. The first implementation is pleasantly short:

await db.query(
  `UPDATE users SET display_name = '${req.body.displayName}'
   WHERE id = '${req.params.id}'`
);
res.json(await loadUser(req.params.id));

The endpoint has no single spectacular mistake. It has a chain of small, independent assumptions: the body has the expected shape, the identifier has one representation, the caller owns the record, the database will treat values as data, and the returned row contains nothing private. An attacker needs only one assumption to be false.

Secure coding makes those assumptions executable. At every boundary, the code should make five things visible: what shape is accepted, whose authority permits the action, which operations can interpret data as instructions, what state may change, and how rejection is tested and recorded.

Secure code path showing validation, authentication, authorization, business logic, persistence, logging, response, and rejected paths for invalid input, denied access, unsafe query, and unsafe error.
Checks belong on the execution path. A review should be able to follow accepted and rejected data through validation, authorization, persistence, evidence, and response.

Give the boundary a narrow language

Treat a request as bytes, not as a partially trusted business object. Parse it once into a deliberately smaller type. Put bounds on the parser itself before examining field values: request size, nesting depth, collection length, and time or work where the format permits pathological inputs.

The profile update accepts exactly two optional fields. It rejects an empty change, unknown keys, malformed Unicode, and values outside their lengths. It also converts the URL identifier into one canonical representation before any comparison or lookup:

const userId = parseCanonicalUserId(req.params.id);
const change = parseProfileChange(req.body, {
  allowed: ["displayName", "timezone"],
  maxLength: { displayName: 80, timezone: 64 },
  rejectUnknown: true,
  requireAtLeastOne: true
});

await requireCanEditProfile(req.auth, userId);
await updateUserProfile(userId, change);

An allow-list matters here because a generic object-to-record mapper can turn an innocent-looking extra field such as role, tenant_id, or mfa_disabled into a privilege change. Type validation alone would accept a perfectly well-formed attack. Authorization is separate for the same reason: a syntactically valid user ID can name somebody else’s record.

Canonicalization must happen before a security decision, not after it. If a path guard checks one spelling and the filesystem, cache, or database resolves another, the check protects a different object. Define whether identifiers are case-sensitive, how Unicode is normalized, and whether encoded separators, alternate IP forms, duplicate JSON keys, or repeated query parameters are rejected. Do not silently let different layers choose.

Negative tests should submit unknown and duplicate fields, wrong scalar types, empty and oversized values, excessive nesting, malformed encodings, alternate identifier forms, another tenant’s ID, and a direct request that bypasses the frontend. The expected result includes no state change, a stable safe error, and the required security event without the hostile payload.

Keep data out of instruction channels

The dangerous line in the first implementation is not merely an escaping failure. It lets a value enter a channel in which the database expects a program. Parameter binding keeps the SQL structure fixed:

await db.query(
  `UPDATE users
      SET display_name = $1, timezone = $2
    WHERE id = $3 AND tenant_id = $4`,
  [change.displayName, change.timezone, userId, req.auth.tenantId]
);

Dynamic identifiers cannot usually be bound as values. If a report can select a sort column, map a small public vocabulary to fixed SQL fragments; never copy the requested string into the query. Apply the same rule to document databases: a field expected to be a string must not accept an object containing operators.

const sortSql = {
  newest: "created_at DESC",
  name: "display_name ASC"
}[input.sort];

if (!sortSql) throw new InvalidInput("unsupported sort");
const sql = `SELECT id, display_name FROM users ORDER BY ${sortSql}`;

Shells, templates, directory filters, expression engines, and deserializers are other instruction channels. Prefer an API that does not invoke a command interpreter:

// Unsafe: a filename can add shell syntax.
exec(`convert ${uploadPath} ${previewPath}`);

// Safer: a fixed executable receives distinct arguments without a shell.
spawn("/usr/bin/convert", [uploadPath, previewPath], {
  shell: false,
  timeout: 5_000
});

Do not allow users to supply server-side templates. Render untrusted values through a framework that escapes for the destination context, and review any raw-HTML or equivalent escape hatch. For LDAP or other directory queries, use a typed or parameterized filter builder rather than concatenating filter text. For deserialization, accept a data-only format into an explicit schema; do not let a payload name classes, constructors, callbacks, or polymorphic runtime types.

The review question is exact: can this value change the instruction being executed? Test it with quotes, delimiters, operators, template expressions, shell metacharacters, serialized type tags, and valid-but-unexpected objects. A test that only sends ordinary punctuation does not exercise the boundary.

Encode for the destination

Validation does not make a string universally safe. The destination decides the encoding. HTML text, an HTML attribute, JavaScript source, a URL component, an LDAP filter, XML, a log record, and a database command have different grammars. SQL values belong in parameters and shell arguments belong in an argument array; neither problem is solved by HTML escaping.

Keep untrusted values out of executable JavaScript and CSS contexts. Let the web framework escape HTML text and attributes by default. Construct URLs with a URL API, encode individual parameters, and restrict redirects to known local paths or an exact allow-list of origins. Configure XML parsers to disable external entities and network access. Produce JSON with a serializer, then select response fields explicitly:

const updated = await loadUser(userId, req.auth.tenantId);
res.json({
  id: updated.id,
  displayName: updated.displayName,
  timezone: updated.timezone
});

Returning the whole database row can disclose password hashes, recovery state, internal policy fields, or another tenant key even though the JSON itself is well formed. Response tests should assert both that required fields exist and that forbidden fields never appear. Browser-facing tests should place hostile values in every rendered context and verify behavior under the deployed content security policy, not by searching the output for one favored escape sequence.

Make failure a designed path

An error handler has two audiences. The caller needs a stable category and a request identifier. Operators need enough internal context to investigate. The attacker should receive neither a stack trace nor a map of which hidden object exists.

try {
  await changeProfile(req);
} catch (error) {
  const publicError = classifyForCaller(error);

  securityLog.warn("profile.change.rejected", {
    requestId: req.id,
    actorId: req.auth?.actorId,
    tenantId: req.auth?.tenantId,
    reason: publicError.reasonCode
  });

  res.status(publicError.status).json({
    error: publicError.name,
    requestId: req.id
  });
}

The logger receives selected fields, not the request object or the exception by default. Passwords, tokens, cookies, connection strings, sensitive payloads, and provider responses must not enter logs through success, rejection, timeout, or exception branches. Seed those paths with recognizable canary secrets and search the application output and telemetry pipeline for them.

Fail closed when an unavailable policy service, missing signing key, corrupt permission record, or indeterminate identity would otherwise grant authority. That does not require every dependency failure to stop every feature. Name the safe degraded behavior: a read-only public catalog might continue from a bounded cache, while an administrative write must wait or reject. If the system cannot state which operation is permitted during uncertainty, its failure policy is an accident.

Preserve the decision at the moment of use

The authorization check and update now look sound in one request, but concurrency can separate them. Suppose a tenant transfer revokes the caller while the profile write is waiting, or two password-reset redemptions arrive together. A check performed earlier does not remain true by promise.

Place the decisive condition in the same transaction or atomic operation as the state change. Use a version or compare-and-swap condition when an update depends on the state observed by the caller:

UPDATE users
   SET display_name = $1,
       version = version + 1
 WHERE id = $2
   AND tenant_id = $3
   AND version = $4;

Zero changed rows means the assumption no longer holds; the application must not pretend the write succeeded. The transaction must also read or lock the authoritative membership or policy version on which requireCanEditProfile depends, or include that version in the guarded update. A tenant predicate prevents a cross-tenant write; it does not prove that this actor still has the required role. For one-time tokens, consume and mark the token in one atomic transition. For retried commands, give the operation an idempotency key tied to the authenticated actor, operation, and bounded lifetime, and retain enough result state to distinguish a replay from new work.

Exercise the path under duplicate delivery, reordered messages, concurrent updates, delayed authorization changes, expired and replayed tokens, worker retries, and process failure between the side effect and acknowledgment. State the invariant first—at most one redemption, no cross-tenant write, no privilege after revocation—then make the test demonstrate it.

Treat files and parsers as exposed runtimes

A filename, archive, image, document, or structured payload can reach code far below the HTTP handler. The upload path should generate its own storage key, write outside executable and public roots, deny public access by default, impose compressed and expanded size limits, and verify format from content rather than trusting the declared MIME type or extension.

Never build a destination by joining a user filename to a trusted directory and then assume the prefix survived. Reject absolute paths, traversal segments, alternate separators, device names, and links; resolve the final path and prove it remains beneath the intended root. Create temporary files atomically with restrictive permissions, avoid predictable names, and remove them on every exit path. Serve downloads with a safe generated name and an explicit content type.

Archive extraction needs limits on entry count, per-entry and total expanded size, nesting, links, and destination paths. XML requires external entities and network retrieval disabled. Image, media, document, and binary parsers should be maintained, isolated where the threat warrants it, and run with memory, CPU, and time bounds. Malware scanning may be one gate for risky workflows, but a clean scan does not establish that a file is safe to parse or publish.

Parser tests include truncated input, malformed lengths, deep structures, duplicate keys, decompression bombs, traversal names, links, mixed content, unsupported versions, and many small objects. Fuzz the narrow parser boundary where practical and preserve crashing inputs as regression fixtures.

Narrow unsafe code until its invariants fit on the page

Memory-safe languages and safe library surfaces remove classes of failure; use them where practical. Native extensions, foreign-function boundaries, packet and image parsers, cryptographic bindings, and hand-vectorized routines still put memory or type invariants outside the compiler’s ordinary protection.

Do not approve such a boundary under the label “performance required.” Its review record should answer:

  • What measured requirement makes the unsafe operation necessary, and what safe design was considered?
  • What is the smallest function or module that must be unsafe?
  • Which lifetime, alignment, aliasing, initialization, bounds, ownership, and thread-safety invariants must callers establish?
  • Which inputs and resource use are bounded before entry?
  • Which compiler, linker, runtime, sanitizer, and platform hardening applies?
  • Which unit, property, fuzz, concurrency, and failure-injection tests exercise the boundary?
  • Who owns the code, and what change requires the review to be repeated?

Put the invariants beside the boundary and enforce as many as possible in a safe wrapper. Run sanitizers and architecture-relevant hardening in CI, but do not mistake their silence for a proof that an undocumented invariant holds.

Review one complete path

Return to the profile endpoint and follow a hostile request all the way through. The parser admits only a bounded, canonical business value. Authorization binds the authenticated tenant, object, and current policy version at the state change. Parameters keep values out of the database program. The response selects and encodes only public fields. The error boundary records a stable reason without copying secrets. Concurrency tests show that revocation and version changes invalidate stale writes. The security event carries actor, tenant, object, outcome, and request identity into the evidence pipeline established in Chapter 21.

Before release, a reviewer should be able to point to evidence for each claim:

  • schemas and parser limits, including unknown-field and canonicalization rules;
  • server-side authorization beside the protected operation, with object and tenant negative tests;
  • parameterized instruction channels and an inventory of reviewed escape hatches;
  • destination-specific encoding, redirect restrictions, and minimal responses;
  • safe caller errors, allow-listed diagnostic fields, canary redaction tests, and explicit degraded behavior;
  • atomicity, replay, retry, idempotency, and concurrency invariants;
  • path, archive, parser, storage, scanning, and resource-exhaustion controls;
  • unsafe-code justification, invariants, hardening, tests, and owner.

Chapter 23 will specialize this discipline for login, recovery, sessions, and tokens. The foundation remains the same: authority and hostile data must meet at a narrow boundary whose accepted path, rejected path, and evidence can all be shown in code and exercised under failure.