Skip to content

Never trust user input

A registration form is about as ordinary as web development gets. A few text fields, a submit button, a message when it works.

It is also the front door. Every username, email address, comment and search query a person types is data your application did not write, arriving from a machine you do not control.

Most of it is harmless. It only takes one visitor who's curious about what happens when they type something you didn't plan for.

Treating user input as safe data is the single most expensive assumption in web development.

The form this section attacks

The running example is a registration form with a front end and a back end, and it is deliberately built badly.

PieceWhat it does
front-end/index.htmlThe form markup: inputs, a submit button, containers for success and error messages.
front-end/app.tsReads the inputs, posts to the back end, shows the result.
front-end/types.tsMirrors the back end types so the client knows the shape of a response.
back-end/server.tsBoots Express, parses JSON, mounts the routes, listens on port 3000.
back-end/routes/auth.tsThe /api/register-vulnerable endpoint. It skips validation entirely and echoes raw input back.
back-end/state/mockDb.tsA stand-in for a database, so the signed-up count can move without one.

That endpoint name is a warning label. Nothing about it belongs in production. It's there so the attacks in the next few chapters have somewhere to land.

Stripped to its essentials, the handler does this:

Vulnerable
ts
router.post('/api/register-vulnerable', (req, res) => {
  const { name, email, password } = req.body

  mockDb.users.push({ name, email, password })

  res.json({ success: true, user: { name, email } })
})

Three fields arrive, three fields get stored, two get sent back. Nothing in between asks whether name is a name, whether email has an @ in it, or how long any of them are.

JunoThe form this section attacks This one small form is the whole attack surface for the section. It stays the same the whole way through, and each chapter breaks it in a new way before fixing it.

Working on a deliberately broken app feels strange at first. It's built wrong on purpose, so the failure is visible while you're looking straight at it.

JunoThe form this section attacks One endpoint, one form and a handful of fields is already enough surface for script injection, resource exhaustion and query manipulation. That's the useful shock: it doesn't take a large app to be broken in three directions at once.

Real applications have hundreds of these. The habit worth building is reading any handler that touches req.body and asking what it assumes about what arrived.

JunoThe form this section attacks A surface this small is honest about the input path and quietly dishonest about time. The mock database means an attack can be demonstrated and reset in a second, which is exactly why the focus stays on where values travel.

What it hides is the category of bug you'll meet later. Storage is where input goes to wait.

A value that looks harmless on the way in can turn dangerous months later, when a different page renders it in a different context. No amount of staring at this form will show you that.

Keep it in mind while everything still looks stateless.

The browser cannot enforce anything

HTML gives you validation for free. Mark an input required and the form will not submit empty. Set type="email" and the browser checks the value looks like an address.

That's useful, and it protects nobody.

A person who wants to get past it can turn off JavaScript, edit the attributes in developer tools, or skip the page altogether and send the request straight to the endpoint with curl. Your form is one client. The endpoint accepts requests from anything.

Browser checks are a courtesy to honest users

They catch typos and save a round trip. They aren't a security control, because the attacker decides whether to run them.

So the form starts with browser validation switched off:

html
<form novalidate>

Starting from nothing makes the point hard to forget. Every check that matters gets added deliberately, on the server, where the request cannot be edited on its way in.

JunoThe browser cannot enforce anything The rule is about who is in charge. Anything running in someone's browser is running on their computer, under their control, and they can change it.

The server runs on your machine. That is the only place a check cannot be switched off by the person you are checking.

JunoThe browser cannot enforce anything A quick way to feel this: open the network tab, submit the form once, copy the request as `curl`, then edit the body and send it again. No page, no JavaScript, no validation.

If that request succeeds with values the form would have rejected, you have found the gap between the interface and the endpoint.

JunoThe browser cannot enforce anything Client-side validation still earns its place. It cuts pointless round trips, gives immediate feedback, and keeps the error experience close to the field.

The failure mode is treating it as one of your layers. It sits outside the trust boundary, the line between the part of the system you control and the part you do not. So it belongs in the usability budget and never in the security one.

A team that counts it as a control tends to discover the mistake through an incident report.

Three ways input turns into a bug

The next three chapters follow the same value into three different destinations, and the destination decides what goes wrong.

Where the input ends upWhat can go wrongChapter
Rendered into a pageScript injected by one visitor runs in another visitor's browserCross-site scripting
Consuming server resourcesAn oversized or crafted request exhausts memory, storage or connectionsDenial of service
Concatenated into a queryInput becomes SQL and reads, changes or drops dataSQL injection

The same field can feed all three. A name goes into a page, into storage and into a query, so a single unchecked value has three ways to hurt you.

JunoThree ways input turns into a bug The question that keeps this simple: where does this value go next?

Into the HTML of a page, into the database, into a query, into an email? Each destination has its own way of misreading text as an instruction.

JunoThree ways input turns into a bug Trace one field end to end before reading the attack chapters. Take the name field: it is read in `app.ts`, posted to the endpoint, stored, echoed back, and rendered into the success message.

Five places. Each one is a chance for the value to be interpreted instead of displayed.

JunoThree ways input turns into a bug These three are the demonstrable ones, and the pattern behind them is broader. Any time text crosses from data into a language, the parser on the other side gets to decide what it means: HTML, SQL, a shell command, a template, a file path.

Recognising that shape matters more than memorising payloads, because the payloads change and the shape does not.

No single fix, several layers

Each vulnerability has a defence, and none of them is the whole answer. The term for stacking them is defence in depth: assume any one layer can fail, and make sure something behind it still holds.

For this form, the layers are:

  1. Browser validation catches typos and saves a round trip. It stops nothing determined.
  2. Schema validation on the server decides whether the request is even the right shape before a handler touches it. Later in the section this becomes Zod, a library for describing the shape you expect and checking data against it.
  3. Safe output escapes values when they are rendered, so stored text stays text.
  4. Parameterized queries keep the structure of a query separate from the data going into it.

An attack has to get through all four. A mistake in one is a bug; a mistake in one with no other layers is an incident.

JunoNo single fix, several layers Layers are the security version of not putting all your eggs in one basket.

You'll still get one of them wrong sometimes. Having four is what makes being wrong once survivable.

JunoNo single fix, several layers The layers do different jobs, so one cannot substitute for another. Validation decides whether to accept a value. Escaping decides how to render it safely. Parameterization decides how to send it to a database.

A schema that accepts only sensible names does not make a concatenated query safe, and a parameterized query does not make the same value safe inside a page.

Layer two is worth a library rather than hand-rolled checks. A schema gives you one source of truth instead of rules scattered between HTML attributes and handler code.

You also get error messages you control instead of the browser's generic ones, room for business rules the browser has no way to express, and the same definition usable on the server and in the page.

JunoNo single fix, several layers Where a layer sits matters as much as whether it exists. Validation belongs at the boundary, before business logic, so nothing downstream has to wonder what it received. Escaping belongs at output, because only the renderer knows the destination context.

Escaping on the way in is the classic mistake. It stores mangled data, breaks the moment a second destination appears, and gives you a database full of &amp;amp; that nobody can safely undo. Validate on entry, escape on exit.

Three words get used as if they mean the same thing, and they name different decisions:

  • Validation asks whether a value is acceptable and rejects it if not. An age of -4 fails.
  • Sanitization edits the value to strip unwanted parts, and keeps what is left.
  • Escaping leaves the value alone and encodes it for one destination, turning < into &amp;lt; on the way into HTML.

Reach for validation first. Rejecting a bad value is easier to reason about than repairing one, and sanitization is the last resort: every filter that strips dangerous content is a guess at what dangerous means.

Try it

Here is the registration handler again:

Vulnerable
ts
router.post('/api/register-vulnerable', (req, res) => {
  const { name, email, password } = req.body

  mockDb.users.push({ name, email, password })

  res.json({ success: true, user: { name, email } })
})

Answer three questions about it:

  1. What does it assume about the values in req.body?
  2. Which of those assumptions would still hold if the request came from curl instead of the form?
  3. Where does each field travel after the handler receives it?
Compare your answers

1. What it assumes. Five things, none of them checked:

  • All three fields turned up.
  • Each one is a string.
  • None is unreasonably long.
  • email is actually an email.
  • None of them will be read as an instruction by whatever handles them next.

Every one of those is a hope.

2. What survives curl. None of them. The only thing enforcing any of it was the form's own markup, and a request sent straight to the endpoint never goes near the form. The handler behaves identically either way, because it cannot tell the difference.

3. Where the fields travel. name and email go into storage, then come back in the response, which the front end renders into the success message. One submission, and each value has reached both a page and a data store.

password goes into storage as typed, in plain text, which is its own bug: it should be hashed and never kept in a readable form. Swap the mock store for a real database and all three reach a query as well.

Those assumptions are the ones the next three chapters break.

Where this goes next

The form is set up, the browser is no longer pretending to protect it, and every value it accepts is currently trusted.

Cross-site scripting starts the demolition, with a name field that runs code in someone else's browser.