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.
| Piece | What it does |
|---|---|
front-end/index.html | The form markup: inputs, a submit button, containers for success and error messages. |
front-end/app.ts | Reads the inputs, posts to the back end, shows the result. |
front-end/types.ts | Mirrors the back end types so the client knows the shape of a response. |
back-end/server.ts | Boots Express, parses JSON, mounts the routes, listens on port 3000. |
back-end/routes/auth.ts | The /api/register-vulnerable endpoint. It skips validation entirely and echoes raw input back. |
back-end/state/mockDb.ts | A 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:
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.
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.
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:
<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.
The server runs on your machine. That is the only place a check cannot be switched off by the person you are checking.
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 up | What can go wrong | Chapter |
|---|---|---|
| Rendered into a page | Script injected by one visitor runs in another visitor's browser | Cross-site scripting |
| Consuming server resources | An oversized or crafted request exhausts memory, storage or connections | Denial of service |
| Concatenated into a query | Input becomes SQL and reads, changes or drops data | SQL 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.
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.
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:
- Browser validation catches typos and saves a round trip. It stops nothing determined.
- 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.
- Safe output escapes values when they are rendered, so stored text stays text.
- 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.
You'll still get one of them wrong sometimes. Having four is what makes being wrong once survivable.
Try it
Here is the registration handler again:
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:
- What does it assume about the values in
req.body? - Which of those assumptions would still hold if the request came from
curlinstead of the form? - 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.
emailis 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.

