users table is just... gone. That is SQL injection, and it is one of the oldest and nastiest bugs on the web. The good news: once you see how it happens, fixing it is easy and you never have to fear it again.
The idea in one line
SQL injection happens when you glue user input straight into your query as text, so the user can sneak in their own SQL, and the fix is to send the input as data, never as part of the query.
The metaphor: a form letter with blanks
Imagine a form letter:
Dear __________, your order #_______ is ready.
You fill the blanks with a name and a number. The sentence shape never changes. The blanks are just blanks. Safe.
Now imagine instead you hand a stranger your pen and say "write the whole letter yourself, I trust you." A nice person writes their name. A sneaky person writes "Sara. P.S. give me everyone's password." You just let them rewrite the sentence, not only fill the blank.
That is the whole story. Gluing input into SQL = handing over the pen. Using blanks (placeholders) = a safe form letter.
How injection actually happens
Here is the classic mistake. You build the query by adding strings together:
// DANGER: input is glued straight into the query text
const email = req.body.email;
const sql = "SELECT * FROM users WHERE email = '" + email + "'";
db.query(sql);
If a normal person types sara@mail.com, the query becomes:
SELECT * FROM users WHERE email = 'sara@mail.com'
Fine. But what if someone types this into the email box?
' OR '1'='1
Now your glued string becomes:
SELECT * FROM users WHERE email = '' OR '1'='1'
'1'='1' is always true, so the WHERE matches every row. The attacker just dumped your entire users table. No password needed.
It gets worse. Imagine someone types:
'; DROP TABLE users; --
Your query turns into:
SELECT * FROM users WHERE email = ''; DROP TABLE users; --'
That runs two commands. The second one deletes your whole users table. The -- at the end is a SQL comment that hides the leftover quote. (There is a famous xkcd comic about a kid literally named "Robert'); DROP TABLE Students; --" whose school lost all its records. Now you know why it is funny.)
The user did not "hack" anything clever. They just filled the blank with a pen you handed them.
The fix: parameterized queries (use the blanks)
Never build SQL by gluing input. Instead, write the query with placeholders, and pass the values separately. The database keeps them apart.
In Postgres the placeholders look like 1ドル, 2ドル:
// SAFE: the query shape is fixed, email is sent as data
const sql = "SELECT * FROM users WHERE email = 1ドル";
db.query(sql, [email]); // email goes in its own little box
In MySQL and many other drivers, the placeholder is a ?:
// SAFE: same idea, question-mark style
const sql = "SELECT * FROM users WHERE email = ?";
db.query(sql, [email]);
Now if someone types ' OR '1'='1, that whole thing is treated as one literal email string. The database goes looking for a user whose email is literally ' OR '1'='1 and finds nobody. No table dump. No drama.
Why this is actually safe
This is the key idea, so slow down here.
With placeholders, the database reads the query shape first, before it ever sees the user's value:
Step 1: lock the shape --> SELECT * FROM users WHERE email = (blank)
Step 2: drop in the data --> the blank gets "' OR '1'='1" as plain text
Because the shape is locked in step 1, nothing in the data can add a new OR, a new ;, or a new command. The data can never become code. The pen stays in your hand. That is the whole magic.
Extra layers of safety (defense in depth)
Parameterized queries are the big one. These help too:
-
Use an ORM or query builder (Prisma, Sequelize, Knex). They use placeholders for you under the hood. Just know: if you drop down to a raw query and glue strings, you are wide open again. The ORM does not save you there.
-
Validate and constrain input. An email should look like an email. An id should be a number. Reject junk early.
-
Least privilege for the DB user. The account your app logs in with should not be allowed to
DROP TABLE if it only ever reads and inserts. Then even a slip cannot wreck everything.
-
Do not leak raw SQL errors to users. An error like
syntax error near 'DROP' tells an attacker exactly how to probe you. Show a friendly message, log the details privately.
Gotchas juniors hit
-
"I will just escape the quotes myself." Hand-escaping is a trap. There are edge cases, different databases, and encodings you will miss. Let the driver do it with placeholders.
-
"I use an ORM, so I am 100 percent safe." Only if you stick to its safe methods. A raw string query inside an ORM is just as dangerous as plain string gluing.
-
"My form validates the input in the browser." Client-side checks are for friendliness, not security. Anyone can skip your form and hit your API directly. Always validate and parameterize on the server.
Recap
- SQL injection happens when user input is glued into the query as text, so it becomes runnable SQL.
- Classic attacks:
' OR '1'='1 returns everyone, '; DROP TABLE users; -- destroys data.
- The fix is parameterized queries: placeholders (
1ドル or ?) with values passed separately.
- It is safe because the query shape is locked first, so data can never turn into code.
- Add backup layers: ORMs (carefully), input validation, least privilege, and hidden error details.
Your turn
Take this vulnerable line and rewrite it safely with a placeholder:
const sql = "SELECT * FROM orders WHERE user_id = " + userId;
Which placeholder style would you use, and where does userId go now? If you can explain to a friend why the safe version cannot be tricked by 1 OR 1=1, you have got it. Up next, Part 14: Window Functions, where we earn the ninja headband.