Advanced Track
SQL Injection Defenses & Secure Query Practices
By the end of this lesson you'll understand exactly how SQL injection turns user input into runaway SQL — and you'll be able to shut it down with parameterised queries, allow-lists, and least-privilege accounts. This is the single most important security skill in all of databases.
What You'll Learn
- ✓How string-concatenated queries let attackers inject SQL
- ✓The classic attacks: ' OR '1'='1 and '; DROP TABLE --
- ✓Parameterised queries / prepared statements — the #1 fix
- ✓Input validation and allow-lists for table/column names
- ✓Least-privilege DB accounts to limit the blast radius
- ✓ORM safety, its raw-query escape hatches, and stored-procedure caveats
Our Sample Table: users
The attacks in this lesson target a login that reads from this users table. Notice that row 1 is the admin — that matters, because a bypass logs the attacker in as the first matching row.
Result:
| id | username | role | password |
|---|---|---|---|
| 1 | admin | superuser | •••• (hashed) |
| 2 | alice | member | •••• (hashed) |
| 3 | bob | member | •••• (hashed) |
1. How SQL Injection Works
SQL injection happens when user input gets glued directly into a query string, so the database can't tell your code apart from their text. It's the top web vulnerability on the OWASP list — and it's 100% preventable.
✉️ Real-world analogy
Imagine dictating a form letter to an assistant: "Dear ___, ...". You expect a name. But the visitor says "Bob. Also, shred every file in the cabinet." If your assistant blindly writes down everything, the extra sentence becomes an instruction. A concatenated query is that gullible assistant — and the quote character ' is how the attacker ends the "name" and starts dictating commands.
Below is the vulnerable pattern. The nameInput isn't a value to the database — once it's pasted into the string, it's part of the SQL itself.
The vulnerable concatenation pattern
Why pasting input into a query string is dangerous.
-- ☠️ THE VULNERABLE PATTERN: building SQL by gluing strings together
-- This is pseudo-code for what your APP does, not SQL you run directly.
-- Your login code pastes the user's typed input straight into the query:
-- query = "SELECT id, username, role FROM users "
-- + "WHERE username = '" + nameInput + "' "
-- + "AND password = '" + passInput + "'"
-- Normal input → nameInput = alice , passInput = secret123
SELECT id, username, role FROM users
WHERE username = 'alice' AND
...Now watch what a single quote does. The attacker's text closes the string early, adds OR '1'='1' (true for every row), and uses -- to comment out the rest of your query.
Attack 1 — the ' OR '1'='1 auth bypass
Make the WHERE clause match every row.
-- ☠️ ATTACK 1: the classic auth bypass ' OR '1'='1
-- The attacker types this into the USERNAME box: ' OR '1'='1' --
-- After string-concatenation, your query becomes:
SELECT id, username, role FROM users
WHERE username = '' OR '1'='1' --' AND password = '';
-- ^^^^^^^^^^^^ ^^
-- always TRUE for every row -- comments out the rest of the line
-- '1'='1' is true for EVERY row, so the WHERE matches everyone.
-- The password check is commented out by the -- entirely.
...Result — what the bypass returns — every user, attacker logged in as admin:
| id | username | role |
|---|---|---|
| 1 | admin | superuser |
| 2 | alice | member |
| 3 | bob | member |
The same hole lets an attacker stack a whole new statement after a semicolon — including a destructive one like DROP TABLE — or bolt on a UNION SELECT to siphon data out of an unrelated table.
Attack 2 — '; DROP TABLE users; -- and UNION data theft
Stacked statements and cross-table extraction.
-- ☠️ ATTACK 2: stacked statement '; DROP TABLE users; --
-- Some drivers let several statements run if separated by ;
-- The attacker types: '; DROP TABLE users; --
SELECT id, username, role FROM users WHERE username = '';
DROP TABLE users; -- a brand-new, attacker-written statement
-- --' AND password = '' -- the leftover is commented out
-- If the database connection is allowed to run DDL, your users
-- table is now GONE. The same trick reads other tables too:
-- ' UNION SEL
...Your Turn (a): spot why it's vulnerable
Fill in the blanks to explain the flaw and to show exactly what input breaks this concatenated query. The expected answer is in the comments so you can self-check.
🎯 Your Turn: what breaks it?
Name the flaw and the input that exploits it.
-- 🎯 YOUR TURN (a) — spot the flaw, then break it on paper
-- This login query is built by string concatenation. Fill in the blanks.
-- query = "SELECT id FROM users WHERE email = '" + emailInput + "'"
-- 1) What makes this UNSAFE?
-- 👉 The user's input becomes part of the SQL ___ , not just a value.
-- (one word: "code")
-- 2) Type this into the email box and write the query it produces:
-- emailInput = ' OR '1'='1' --
SELECT id FROM users WHERE email = '___'; -- 👉 paste the
...2. The #1 Fix: Parameterised Queries
A parameterised query (also called a prepared statement) splits the SQL from the data. You write the query with a placeholder — ?, $1, or :name depending on your engine — and hand the value over separately. The database compiles the query first, then slots your value in as pure data. Quotes in the input can no longer "escape" into the SQL, because the SQL is already finished.
Vulnerable vs. safe, side by side:
❌ VULNERABLE — concatenated
Concatenated query
Input becomes SQL code.
-- ❌ VULNERABLE — user input concatenated into the SQL string
-- (Python + psycopg, but the flaw is identical in every language)
name = request.form["username"] # whatever the user typed
sql = "SELECT id, role FROM users WHERE username = '" + name + "'"
cursor.execute(sql)
-- Input alice → ...WHERE username = 'alice' ✅
-- Input ' OR '1'='1 → ...WHERE username = '' OR '1'='1' ☠️ returns all rows
-- The database cannot tell your code from their text.✅ SAFE — parameterised
Parameterised query
Input stays as data.
-- ✅ SAFE — parameterised query (a.k.a. prepared statement)
-- The ? / %s is a PLACEHOLDER. The value travels separately from the SQL.
name = request.form["username"]
sql = "SELECT id, role FROM users WHERE username = %s" -- note: NO quotes
cursor.execute(sql, (name,)) -- value passed as DATA, not code
-- 1. The DB parses & compiles the query template FIRST.
-- 2. Then it binds your value into the prepared slot.
-- 3. ' OR '1'='1 is now searched for LITERALLY as a username.
-- N
...Want to see the separation? PostgreSQL exposes it directly with PREPARE and EXECUTE: the plan is built once, and every value you pass afterwards is bound into a typed hole.
PREPARE / EXECUTE — watch the separation
The query is compiled before your value is seen.
-- WHY PARAMETERS WIN: the SQL is fixed before your value is seen.
-- PostgreSQL lets you watch it happen with PREPARE / EXECUTE:
PREPARE find_user (text) AS
SELECT id, username, role FROM users WHERE username = $1;
-- The plan is compiled now. $1 is a typed hole, not text to parse.
EXECUTE find_user('alice'); -- ✅ one row for alice
EXECUTE find_user(''' OR ''1''=''1'); -- searches for that exact name
-- → 0 rows. The quotes are just characters inside a value; they c
...Your Turn (b): rewrite it with a placeholder
Take the same vulnerable login and make it safe by replacing the concatenation with a parameter placeholder. One blank — use ? (or $1 / :email).
🎯 Your Turn: parameterise it
Swap concatenation for a placeholder.
-- 🎯 YOUR TURN (b) — make it injection-proof with a placeholder
-- Rewrite the SAME login using a parameter instead of concatenation.
-- Replace the ___ with a placeholder (use ? — or $1 / :email on your engine).
-- ❌ Before (vulnerable):
-- sql = "SELECT id FROM users WHERE email = '" + emailInput + "'"
-- ✅ After (safe): no quotes around the placeholder, value passed separately
sql = "SELECT id FROM users WHERE email = ___" -- 👉 put the placeholder here
cursor.execute(sql, (emailInput
...3. Defence in Depth (the other layers)
Parameters are the fix — but good security stacks layers, so a single mistake isn't fatal. The catch: you can never bind a table or column name as a parameter. For those, validate against an allow-list (a fixed set of names you accept). Escaping input by hand is a brittle last resort — get it wrong once and you're exposed.
Input validation & allow-lists
Protect identifiers and add belt-and-braces checks.
-- DEFENCE: input validation & allow-lists (a SECOND layer, not the first)
-- Parameters protect VALUES. But you can never bind a table or column
-- name as a parameter — so for those, check against an allow-list.
-- ❌ WRONG: trusting the client / pasting a column name straight in
-- sql = "SELECT * FROM products ORDER BY " + sortColumn -- injectable!
-- ✅ RIGHT: only permit known-good identifiers (an allow-list / whitelist)
-- allowed = {"name", "price", "created_at"}
-- if sortCol
...4. Least-Privilege Database Accounts
Assume an attack might one day slip through. The login your app connects with should only be able to do the app's job — no DROP, no ALTER, no superuser. Then even a successful injection runs into permission denied for the worst operations.
Least privilege with GRANT / REVOKE
Cap what a compromised login can do.
-- DEFENCE: least-privilege database accounts (limit the blast radius)
-- If an attack ever lands, what the login is ALLOWED to do caps the damage.
-- Create a restricted role for the application:
CREATE ROLE app_user WITH LOGIN PASSWORD 'a-strong-secret';
-- Grant ONLY what the app needs — no DROP, no ALTER, no superuser:
GRANT SELECT, INSERT, UPDATE ON products, orders, customers TO app_user;
REVOKE CREATE ON SCHEMA public FROM app_user;
-- Now, even if injection sneaks through:
-- '; DRO
...5. ORMs and Stored-Procedure Caveats
An ORM (Object-Relational Mapper — the library that turns objects into SQL, like Django, SQLAlchemy, ActiveRecord, or Prisma) parameterises for you by default, which is a big part of why ORMs are safer. But every ORM has a raw SQL escape hatch, and the moment you use it you're back to manual safety. Stored procedures aren't automatically safe either: if a procedure builds SQL from concatenated text and runs it with EXECUTE, it's just as injectable.
ORM defaults, raw-query escape hatches & procedures
Where ORMs protect you — and where they stop.
-- DEFENCE: ORM safety — and its escape hatches
-- ORMs (Django, SQLAlchemy, ActiveRecord, Prisma, Hibernate, EF) build
-- parameterised SQL for you BY DEFAULT — that is a big reason to use one.
-- ✅ SAFE — the ORM parameterises this automatically:
-- User.objects.filter(username=name)
-- db.users.findFirst({ where: { username: name } })
-- ⚠️ ESCAPE HATCH — raw SQL methods bypass that protection. Still parameterise:
-- ✅ Django: User.objects.raw("SELECT * FROM users WHERE id = %s", [us
...Common Mistakes (and the fix)
- Escaping quotes by hand: doubling
'into''yourself is fragile — encodings, Unicode, and numeric contexts slip past it. Use a parameter and let the driver handle it. - Trusting client-side validation: JavaScript checks in the browser are a UX nicety, not security. Attackers call your API directly with
curl. Validate and parameterise on the server. - Quoting the placeholder: writing
WHERE name = '?'(or'%s') breaks it — that's now a literal question mark. The placeholder takes no quotes:WHERE name = ?. - Dynamic SQL inside a procedure:
EXECUTE 'SELECT ... = ' || p_idis injectable even though it's "in the database." UseEXECUTE ... USING p_idwith a placeholder. - The ORM raw-query escape hatch: an f-string/concatenation in
.raw(...)or.query(...)bypasses the ORM's protection. Pass parameters:.raw(sql, [id]), not.raw(f"...{id}").
📘 Quick Reference — parameter syntax by language/engine
| Language / engine | Parameterised query |
|---|---|
| Python (psycopg / sqlite3) | cursor.execute("... WHERE id = %s", (user_id,)) |
| Node.js (pg) | db.query("... WHERE id = $1", [userId]) |
| Node.js (mysql2) | conn.query("... WHERE id = ?", [userId]) |
| Java (JDBC) | ps = conn.prepareStatement("... id = ?"); ps.setInt(1, id); |
| PHP (PDO) | $stmt = $pdo->prepare("... id = :id"); $stmt->execute(['id'=>$id]); |
| C# (ADO.NET) | cmd.Parameters.AddWithValue("@id", id); |
| PostgreSQL | PREPARE q (int) AS SELECT ... = $1; EXECUTE q(42); |
Placeholder style varies (?, $1, :name, %s, @id) but the rule is the same everywhere: SQL with a placeholder, value passed separately.
Frequently Asked Questions
Q: If I parameterise everything, do I still need input validation?
Yes — as a second layer. Parameters stop injection in values, but they can't protect a table or column name you build dynamically, and validation enforces business rules (length, format). Use both; never let validation be your only defence.
Q: Can't I just escape the quotes myself?
Don't. Hand-escaping is notoriously easy to get wrong — character sets, Unicode look-alikes, and numeric contexts all create holes. A parameterised query is simpler and safer, so reach for escaping only as a genuine last resort.
Q: Are stored procedures automatically safe from injection?
No. A procedure that builds a query by concatenating strings and runs it with dynamic EXECUTE/EXEC is just as injectable as application code. Use parameters inside the procedure too (EXECUTE ... USING).
Q: My ORM builds the SQL for me — am I covered?
Mostly. ORMs parameterise normal queries by default, which is great. The risk is the raw SQL escape hatch: an f-string or string concatenation passed to .raw()/.query() bypasses that protection. Always pass values as parameters, even in raw queries.
Mini-Challenge: Lock Down the Login
Put it all together — a brief, a blank canvas, and the expected shape in the comments. Make the two-field login injection-proof, then sanity-check it in a playground.
🎯 Mini-Challenge
Parameterise both username and password.
-- 🎯 MINI-CHALLENGE: make this login injection-proof
-- You are given a vulnerable login that checks a username AND a password.
-- sql = "SELECT id, role FROM users "
-- + "WHERE username = '" + nameIn + "' AND password = '" + passIn + "'"
--
-- Rewrite it using parameter placeholders for BOTH values, then pass the
-- two values separately when you execute it.
--
-- ✅ Expected shape (placeholders may be ? , $1/$2 , or :name/:pass):
-- sql = "SELECT id, role FROM users WHERE username
...🎉 Lesson Complete
- ✅ Injection happens when user input is concatenated into the SQL string
- ✅
' OR '1'='1bypasses auth;'; DROP TABLE --destroys data - ✅ Parameterised queries are the #1 fix — placeholder in the SQL, value passed separately
- ✅ Allow-lists guard table/column names; least privilege caps the blast radius
- ✅ ORMs parameterise by default, but raw queries and dynamic procedures need care
- ✅ Next:
Backup & Restore— protect your data even when something goes wrong
Sign up for free to track which lessons you've completed and get learning reminders.