ValidationGENERALFRAMEWORK-SPECIFICPROTOCOL-SPECIFIC

Every Input Surface

Body, query, path, headers, cookies, files, webhooks, external responses — and the second-order case where your own database hands back something a request wrote.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

Which surfaces carry untrusted input, and what is the specific check each one needs?

The requirement

The service is being reviewed. The question is not "do we validate" but "which of the ways data enters this process have a check, and what does each check do?".

The obvious build

We validate the request body with a schema on every endpoint. That is where user input comes from.

Why it breaks

A sort parameter from the query string is interpolated into ORDER BY, because parameterised queries cover values and not identifiers (SQL Injection).

How it breaks in production
  • A sort parameter from the query string is interpolated into ORDER BY, because parameterised queries cover values and not identifiers (SQL Injection).
  • A path parameter is a well-formed id belonging to another tenant, and the handler loads it because it validated the format (Object-Level Authorization).
  • X-Forwarded-For is read as the client IP and used for rate limiting. It is client-supplied unless the proxy overwrites it, so the limit is bypassed by sending a header (Rate Limiting).
  • An uploaded filename containing ../ is used as a storage key, or a Content-Type of image/png is trusted for a file whose bytes are a script (File Uploads Through the Backend, Path Traversal).
  • A webhook body is parsed and acted on before the signature is verified — or the signature is verified against the re-serialized body rather than the raw bytes, so verification silently never matches (Webhook Signature Verification).
  • An external API adds a field, or returns null where a string was documented, and the value flows into the database and later into someone's browser.
  • A job payload written by yesterday's producer is consumed by today's worker during a rolling deploy, with a field that no longer exists (Rolling Deployments).
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • The Trust Boundary establishes the principle: the process edge is the boundary and nothing crossing it is trusted. This lesson is the inventory, because the principle is easy to agree with and the surfaces are easy to miss.
  • The surfaces differ in who controls them and in what the specific check is. A body needs a schema; a sort parameter needs an allowlist; a webhook needs a signature over raw bytes; a file needs its content sniffed rather than its declared type believed.
  • Some surfaces are untrusted in a way that surprises people: headers set by your own proxy are trustworthy only if the proxy overwrites rather than appends, and cookies are attacker-controlled unless signed or encrypted.
  • The second-order case is the one most often missed: a value that entered as a request, was stored, and is read back later. It is untrusted data with a trusted-looking source, and it is how stored XSS and delayed injection work (XSS Defense by Output Context).
  • Egress is an input surface in disguise: a URL supplied by a caller and fetched by your server turns your process into the attacker's HTTP client, with access to the internal network and the cloud metadata endpoint (SSRF — When the Backend Fetches a URL).
  • For agent-enabled backends the same rule extends: model output and tool results are untrusted input. A tool call is a client calling an endpoint, and the endpoint validates it exactly as it would any other (A Tool Call Is a Backend Call).

The inventory

This table is the lesson. It is meant to be read against a real service with a specific question per row: does anything check this, and is the check the one in the third column?

Note how few of the checks are "run a schema over it". Each surface has a characteristic weakness, and applying the body's remedy to all of them leaves most of them open.

SurfaceWho controls itThe check it specifically needsWhat it becomes unchecked
Request bodyThe caller, entirelySchema, size limit, unknown-field policyMass assignment, oversized-payload DoS (Mass Assignment and Over-Posting)
Query parametersThe caller, entirelySchema and an allowlist for anything becoming an identifierInjection through ORDER BY, unbounded page sizes (SQL Injection)
Path parametersThe caller, entirelyFormat check and an object-level authorization checkIDOR — a valid id belonging to someone else (Object-Level Authorization)
HeadersThe caller, except those a proxy overwritesTrusted-proxy config; count, size and encoding limitsRate-limit bypass via X-Forwarded-For, cache poisoning
CookiesThe caller, unless signed or encryptedIntegrity (signature), plus the same parsing as any inputSession forgery, privilege escalation (Cookies and Their Attributes)
Uploaded filesThe caller: bytes, name and declared typeSize cap before read, content sniffing, generated storage keyPath traversal, stored malware, storage exhaustion (File Upload Security)
Webhook payloadsAnyone who can reach the URLHMAC over the raw bytes, timestamp window, replay checkForged state changes from an unauthenticated POST (Webhook Signature Verification)
External API responsesThe provider, plus anyone who compromised themParse against your expected shape; fail loudly on mismatchBad data stored, then rendered — second-order injection
Queue / job payloadsA producer, possibly an older deployed versionSchema with version tolerance; treat as an external contractWorker crash loops and poison messages (Dead-Letter Queues)
Rows read backWhoever wrote them, originally a requestEncode for the destination context at outputStored XSS, delayed injection (XSS Defense by Output Context)
Environment / configWhoever deploys — and it is all stringsParse and validate once at boot; exit on failureA typo silently disabling a control (Validate at Startup, Fail Loudly)
Caller-supplied URLsThe callerAllowlist, scheme check, re-resolve and re-check after DNSSSRF into the internal network or metadata service (SSRF Defense in Depth)
Model / tool outputPartly the model, partly whatever it readValidate as a request; authorize server-side, never in the promptPrompt injection driving privileged actions (Agent Authorization)

The parameters that are not values

DATABASE-SPECIFICThe identifier problem is universal, but the escape hatches differ: Postgres has quote_ident and format(%I) for dynamic SQL, MySQL has backtick quoting with its own rules, and neither is a substitute for an allowlist because both still let the caller name *any* column, including ones you did not intend to expose.

Parameterised queries protect *values*. They cannot protect identifiers — a column name, a table name, a sort direction — because those are part of the statement's structure, and no placeholder exists for them. This is the single most common way a codebase that "uses an ORM everywhere" still has an injection.

The fix is not escaping. It is an allowlist: a fixed map from a caller-supplied token to a value you wrote yourself, with anything unrecognised rejected.

A sort parameter, done twice
1// WRONG: parameterised value, interpolated identifier
2const { sort = 'created_at', dir = 'asc' } = req.query as Record<string, string>
3await db.query(
4 `SELECT * FROM orders WHERE org_id = $1 ORDER BY ${sort} ${dir} LIMIT 50`,
5 [orgId], // org_id is safe. `sort` is not a value and cannot be bound.
6)
7// ?sort=(SELECT CASE WHEN (SELECT substr(password_hash,1,1) FROM users
8// WHERE id=1)='$' THEN id ELSE name END) -> a working blind oracle
9
10// RIGHT: the caller picks a key; you own every character that reaches the SQL
11const SORTABLE = {
12 created: 'o.created_at',
13 total: 'o.total_cents',
14 customer: 'c.name',
15} as const
16const DIRECTIONS = { asc: 'ASC', desc: 'DESC' } as const
17
18const column = SORTABLE[req.query.sort as keyof typeof SORTABLE]
19const dir = DIRECTIONS[req.query.dir as keyof typeof DIRECTIONS]
20if (!column || !dir) return badRequest('sort') // reject, do not default silently
21
22await db.query(
23 `SELECT o.* FROM orders o JOIN customers c ON c.id = o.customer_id
24 WHERE o.org_id = $1 ORDER BY ${column} ${dir}, o.id LIMIT $2`,
25 [orgId, Math.min(Number(req.query.limit) || 50, 200)], // bound the page too
26)

Three things beyond the injection: rejecting an unknown sort key rather than defaulting means a client typo is visible instead of silently ignored; o.id as a tiebreaker stops rows repeating across pages; and the limit is capped because an unbounded page size is a memory incident with a valid-looking request (Pagination That Survives a Large Table).

Second-order input: your own database

The hardest surface to internalise is the one with a trusted-looking source. A value that arrived in a request, passed validation, and was stored is still attacker-supplied data — validation established that it was well-formed, not that it is safe to interpolate into a different context later.

The reason it is missed is that the write and the read are far apart, often in different services, often written by different people, often months apart. The defence is contextual encoding at output rather than a belief about where the value came from.

Data that entered as a request and left as something else
TriggerSymptomCauseResponse
A display name containing <img onerror=...>Script runs in an admin dashboard, with admin sessionStored as valid text, rendered later without HTML encodingEncode at render for the HTML context; never rely on input filtering (XSS Defense by Output Context)
A filename stored from an upload, reused as a pathReads or writes outside the intended directoryThe name is caller-controlled and was only checked for lengthGenerate the storage key; keep the original as metadata only (Path Traversal)
A stored URL fetched by a nightly jobThe job reaches an internal admin endpointIt was validated as a URL, never as an *allowed* destinationAllowlist at fetch time, re-check after DNS resolution (SSRF Defense in Depth)
A CSV export of user-supplied textA formula executes when opened in a spreadsheetA leading = is a formula in the CSV context and was harmless in JSONEscape for the CSV context at export; the destination decides the encoding
A queue payload enqueued before a deployWorkers crash-loop on a field that no longer existsThe payload is a contract between two versions of your own codeVersion the payload; validate on consume (Job Queues, Dead-Letter Queues)
A cached object read back after a type changeA field is undefined where the type says stringCache contents outlive the deploy that changed the shapeInclude a schema version in the cache key (Cache Invalidation)

How to build it

Most important first.

  • Write the inventory down for your service and check each surface has an owner. This is a fifteen-minute exercise that finds real gaps.
  • Validate query and path parameters with the same schema machinery as the body — they are strings from a URL and reach the same code (Query Parameters, Path Parameters).
  • Allowlist anything that becomes an identifier: sort columns, filter fields, table or index names, redirect targets. Parameterisation does not cover these (SQL Injection).
  • Read forwarding headers only through a trusted-proxy configuration that knows how many hops to skip; otherwise they are caller-controlled (The Request Lifecycle).
  • Verify webhook signatures on the raw body bytes, before parsing, and reject on mismatch without a timing-variable comparison (Webhook Signature Verification).
  • For files: cap the size before reading, sniff the content type from the bytes, never use the client filename as a storage key, and store outside the web root (File Uploads Through the Backend).
  • Parse external API responses against your expected shape and fail loudly on mismatch, rather than letting undefined propagate (Calling Something You Do Not Control).
  • Re-validate on read where a stored value crosses into a new context — rendering, a shell, a query, a URL — because the safe encoding depends on the destination (Defence in Depth).

What can go wrong

Failure modes
  • A schema on the body only, with query and path parameters read directly off the request object.
  • A global "sanitize all input" middleware that strips characters, breaking legitimate data (an O'Brien surname, a password with <) while stopping nothing specific (Secure Defaults).
  • Signature verification after body parsing, on a re-serialized object whose key order and whitespace differ from the bytes that were signed.
  • Trusting Content-Type or the file extension to decide how to process an upload.
  • A schema for external responses that is stricter than the provider's contract, so a harmless additive change breaks your integration at 3am.
  • Environment variables read and coerced at point of use rather than parsed at boot, so MAX_RETRIES='' becomes 0 and retries are silently disabled (Validate at Startup, Fail Loudly).
  • Validation applied to the request but not to the job payload it enqueues, so the queue becomes an unvalidated path into the same logic (Job Queues).
What can race
  • Time-of-check/time-of-use on a fetched URL: a hostname validated as public can resolve to a private address on the request that follows, which is the DNS-rebinding form of SSRF (SSRF Defense in Depth).
  • An uploaded file validated and then processed by a worker can be replaced between the two if the storage key is caller-influenced (File Uploads Through the Backend).
  • Duplicate webhook deliveries are the normal case, not an anomaly: the same signed payload arrives twice and must be recognised, not merely accepted (Webhook Idempotency).
Security
  • Nearly every serious backend vulnerability class is a surface that was not on someone's list: injection, SSRF, IDOR, path traversal, mass assignment and stored XSS all begin with input from a place nobody thought of as input (Attack Surface).
  • Identity and tenancy come from the authenticated principal, never from a field, header or cookie the caller can set (Tenant Isolation).
  • Cookies are attacker-controlled unless signed or encrypted, and a session cookie without integrity is a session you do not control (Cookies and Their Attributes).
  • A caller-supplied URL that your server fetches must be validated against an allowlist and re-checked after DNS resolution, because a name can resolve to a private address between check and use (SSRF Defense in Depth).
  • For agent backends, prompt injection is untrusted input arriving through a new door: content the model read becomes instructions it acts on, and the backend's authorization must not depend on the model's judgement (Agent Authorization).
Misreads
  • "We validate user input" — user input is one surface. Headers, cookies, webhooks, external responses and your own queue are the ones that get missed.
  • "It came from our database, so it is clean." It is as clean as whatever wrote it, and a request wrote most of it (XSS Defense by Output Context).
  • "It is an internal service, so it is trusted." Internal is a network property, not a data property.
  • "The provider is reputable." Reputable providers ship regressions and get compromised, and your parser meets the payload either way.
  • "Sanitising input is the defence." Encoding at *output*, for the specific destination, is the defence. Input sanitisation destroys data and misses contexts (Defence in Depth).
  • "The webhook came from the right IP." IP allowlists are a weak signal and no substitute for a signature over the raw body (Secure Webhooks).

Operating it

How you see it in production
  • Count rejections per surface, not just per endpoint. A validator that has never rejected anything is either unreachable or not doing what you think.
  • Log the surface and rule on rejection — { surface: 'query', rule: 'sort_allowlist' } — so a probing pattern is visible (Structured Logging).
  • Alert on webhook signature failures. A nonzero steady rate is a misconfigured sender or a key rotation; a spike is someone trying (Inbound Webhooks).
  • Track external-response parse failures per provider. They are the earliest warning that a partner changed something without telling you (Calling Something You Do Not Control).
What changes at 10x and 100x
  • The inventory does not grow with traffic. It grows with integrations: every new webhook sender, provider and queue topic is a new row.
  • At 10x request rate the cheap checks stay cheap; the expensive ones (content sniffing, image decoding, virus scanning) belong in a background job rather than the request path (What Happens After the Bytes Land).
  • At 100x the surfaces multiply through services: an internal caller is still an input surface, and "internal" describes the network path rather than the data (The Trust Boundary).
What this costs
  • Validating every surface is real work with real maintenance, and some of it will never catch anything. That is what a control looks like when it is working.
  • Strict parsing of external responses turns a partner's additive change into your outage. Being strict about fields you use and lenient about fields you ignore is the usual compromise.
  • Validating internal calls duplicates effort and adds latency, in exchange for containing the blast radius of one buggy service.
  • Content sniffing and scanning uploads costs CPU and time, which is why it belongs off the request path — at the cost of a window where the file exists and is not yet cleared (File Uploads Through the Backend).

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALThe inventory applies to any backend on any stack. Which surfaces exist is a function of what your service accepts, not of your framework.
  • FRAMEWORK-SPECIFICWhere a surface is exposed and how easy it is to miss differs sharply. Express gives req.query as untyped strings (and, with the default extended parser, nested objects and arrays from a query string, which surprises people); FastAPI validates query and path parameters through the same Pydantic machinery as the body by default, so they are the *hardest* to miss there; Go requires explicit r.URL.Query().Get per parameter, so nothing is validated unless you write it. The raw-body problem for webhook signatures is universal and framework-specific in its fix: Express needs express.raw() on that route before the JSON parser, FastAPI needs await request.body() before touching the model.
  • PROTOCOL-SPECIFICHTTP/2 and HTTP/3 carry pseudo-headers and enforce lowercase header names, and header-size accounting differs from HTTP/1.1 because of HPACK/QPACK compression — so a limit expressed in raw bytes means something different per version. Duplicate headers are also handled differently by proxy and framework, which is the mechanism behind request smuggling.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.