SQL injection
A login check is a question for the database. Is there a row where the username and the password both match what was typed?
const query = `
SELECT * FROM users
WHERE username = '${username}' AND password = '${password}'
`A row comes back, the credentials were good. No row, they weren't.
That reads like a question. To the database it's a sentence, and the visitor got to write part of it.
A query built by gluing strings together lets the sender decide what the query means.
The bug
SQL is a language, and this query is a program written fresh on every request. Most of it comes from you. Two pieces come from whoever is at the keyboard.
The database never sees that seam. By the time the query arrives, it's one continuous piece of text, and the database has no way of knowing which characters you wrote and which ones a stranger did. It parses the whole thing and does what it says.
That's SQL injection: input that gets read as part of the query instead of as a value inside it. The consequences follow from what SQL can express, which is quite a lot:
- Read rows the requester should never see.
- Change or delete data.
- Run administrative commands against the database system.
- On some setups, reach the operating system underneath.
What you're actually doing is handing the database a finished sentence and hoping the words someone else supplied are only words.
The attack
Leave the password alone. Type this as the username:
' OR 1=1 --Substituted into the template, the database receives:
SELECT * FROM users
WHERE username = '' OR 1=1 --' AND password = ''Three characters did the work:
- The opening
'closes the username string early, so everything after it is read as query syntax rather than as a name. OR 1=1is a condition that's always true, so the wholeWHEREclause is satisfied for every row.--begins a SQL comment, so the password check after it is text the database ignores.
Every user comes back. The application takes the first row as proof of a successful login and lets the attacker in as somebody else.
Point this at your own database
These payloads change and destroy data. That makes them unsafe anywhere with real records, including a staging copy of production. A local database you can drop and rebuild, or a system you have written permission to test.
Close the quote so you're writing query instead of text. Add a condition that's always true. Comment out whatever you didn't want to deal with.
The fix
Stop building the sentence. Describe its structure once, then hand the values over separately:
const query = 'SELECT * FROM users WHERE username = ? AND password = ?'
const [rows] = await db.execute(query, [username, password])The ? marks are placeholders. The exact syntax varies by database and library, $1 and $2 in some, named parameters in others, but the shape is the same everywhere: the query text is fixed before any user value is in the room.
This is called a prepared statement with variable binding, or more commonly a parameterized query.
Here the shape is settled first. The values arrive afterwards, as values, and nothing about them can change a decision that has already been made.
Why the fix works
Nothing here inspects the input or removes anything from it. ' OR 1=1 -- still arrives exactly as typed.
What changed is where it lands. The database already knows it's looking for one username and one password, so the payload gets compared as a username, character for character, against a column of names.
No account is called ' OR 1=1 --. No row comes back, the login fails, and that's the correct answer.
That's the defence at the database layer, and it sits alongside the others this section has been building:
| Layer | What it decides |
|---|---|
| Browser validation | Whether the form submits, for honest users only |
| Schema validation on the server | Whether the request is the right shape to accept at all |
| Safe output | Whether a stored value can become code in a page |
| Parameterized queries | Whether a value can become part of a query |
That's worth holding onto: the safest fixes tend to change what a value is treated as, rather than trying to work out whether it looks dangerous.
Try it
A registration form inserts a name and an email:
const query = `
INSERT INTO users (name, email)
VALUES ('${name}', '${email}')
`Play the attacker. Craft a value for the email field that would delete the users table outright.
Three things to work out: how to stop being inside the quoted value, how to stop being inside the brackets, and how to end one statement so a second one can start.
Compare your answers
The email value is:
'); DROP TABLE users; --Which produces:
INSERT INTO users (name, email)
VALUES ('Dave', ''); DROP TABLE users; --')Four moves, matching the three questions plus a tidy-up:
'closes the string the email was sitting inside.)closes theVALUESbracket, completing theINSERTas a valid statement.;ends that statement, so what follows is read as a new one.--comments out the')left dangling at the end, which would otherwise be a syntax error and cause the whole thing to be rejected.
The last move is the one people miss. Without it the statement is malformed, the database refuses all of it, and the attack fails for reasons unrelated to your defences.
Worth knowing: many drivers reject multiple statements in a single call by default, so this exact payload doesn't always land. That's a configuration protecting you, not a fix. The same hole still reads any data an attacker wants through a UNION SELECT, which needs no second statement at all.
Parameterize the query and the whole string becomes somebody's unusual email address, stored as typed.
Where this goes next
Three chapters, three bugs, one cause. Nothing checked what arrived before the application acted on it, so a value chosen by a stranger decided what the code did.
Each fix so far has been at the point of use: the right property for rendering, a limit on size, a placeholder in a query. Those are necessary, and they're also late: repeated at every destination, and simple to miss once.
Zod fundamentals starts the other half, describing the shape you expect once, at the boundary, and refusing everything that doesn't match.

