You need Python 3 and a text editor — nothing else.
No database server, no web server, no internet: the database used below is a
single file that Python creates for you, because sqlite3 is part
of Python’s standard library. Check with python3 --version
in a terminal (on Windows, py --version); any 3.x is fine. If
that prints nothing useful, do
Introduction to
Python first — it installs Python and runs your first program in
about fifteen minutes.
🔴 Everything here runs against a database you create on your own machine, in a folder you choose. That is the only place any of it belongs. Running these techniques against a system you do not own is a crime in most countries, and the fact that a site is reachable is not permission.
What Is SQL Injection?
SQL injection (SQLi) is a vulnerability that allows an attacker to interfere with the database queries that an application makes. By inserting malicious SQL code into input fields, URL parameters, or HTTP headers, an attacker can manipulate the database query to do something the developer never intended.
The vulnerability exists whenever an application constructs SQL queries by concatenating user input directly into the query string. The database engine cannot distinguish between the developer's intended SQL and the attacker's injected code -- it executes all of it.
Despite being well-understood for over 25 years and having straightforward prevention methods, SQLi remains in the OWASP Top 10 and is still found in production applications. In 2023, OWASP merged it into the broader "Injection" category, which ranked third overall. The root cause is always the same: building queries with string concatenation instead of parameterized queries.
The consequences of a successful SQL injection attack range from unauthorized data access to complete database destruction. An attacker can read sensitive data, modify or delete records, execute administrative operations, and in some cases even gain control of the underlying operating system.
How SQL Injection Works
To understand SQL injection, you need to see how a vulnerable application processes user input. Consider a simple login form.
The Vulnerable Code
# Vulnerable Python code (DO NOT use this pattern)
username = request.form['username']
password = request.form['password']
query = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'"
result = db.execute(query)
When a normal user enters alice and mypassword, the query
becomes:
SELECT * FROM users WHERE username = 'alice' AND password = 'mypassword'
This works correctly. But what if an attacker enters this as the username?
' OR '1'='1' --
The resulting query becomes:
SELECT * FROM users WHERE username = '' OR '1'='1' --' AND password = ''
The OR '1'='1' condition is always true, and the --
comments out the rest of the query (the password check). The database returns all
users, and the application logs the attacker in as the first user in the table --
often the administrator.
Every SQL injection vulnerability begins with the application treating user input
as SQL code rather than as data. The single quote character ' breaks
out of the string literal in the query, allowing the attacker to inject their own
SQL. This is why string concatenation must never be used to build SQL queries with
user input.
Beyond Authentication Bypass
The login bypass above is the classic introductory example, but SQL injection can do far more damage. An attacker who can inject SQL can:
- Extract entire databases -- Read every table, including credentials, personal data, and financial records
- Modify data -- Change prices, elevate user privileges, alter records
- Delete data -- Drop tables or truncate critical data with
DROP TABLEorDELETE FROM - Read system files -- On MySQL,
LOAD_FILE('/etc/passwd')reads files from the server filesystem - Write files -- On MySQL,
INTO OUTFILEcan write files to disk, potentially creating a web shell - Execute OS commands -- On SQL Server,
xp_cmdshellcan execute arbitrary operating system commands
Types of SQL Injection
SQL injection attacks are classified by how the attacker retrieves results from the database. The type determines the attacker's technique, not the severity -- all types can lead to full database compromise.
Classic (In-Band) SQL Injection
In classic SQLi, the results of the injected query are displayed directly on the page. This is the simplest type to exploit because the attacker can immediately see the data they are extracting.
# Vulnerable URL:
https://shop.example.com/product?id=5
# The server runs:
SELECT name, price, description FROM products WHERE id = 5
# Attacker modifies the URL:
https://shop.example.com/product?id=5 OR 1=1
# The server runs:
SELECT name, price, description FROM products WHERE id = 5 OR 1=1
# Returns ALL products instead of just one
Union-Based SQL Injection
Union-based SQLi uses the UNION SELECT statement to append additional
queries to the original. This allows the attacker to extract data from entirely
different tables by combining results into the same output.
# Original query returns 3 columns (name, price, description)
# Attacker injects UNION SELECT to pull data from the users table:
https://shop.example.com/product?id=5 UNION SELECT username, password, email FROM users --
# The page now displays user credentials alongside product data
# The attacker must match the number of columns in the UNION
The key challenge in union-based attacks is determining the number and data types of
columns in the original query. Attackers typically do this by injecting
ORDER BY with increasing column numbers until they get an error, or by
using UNION SELECT NULL, NULL, NULL... until the column count matches.
Blind SQL Injection
Blind SQLi occurs when the application does not display query results or error messages on the page. The attacker cannot directly see the data, so they infer information by observing the application's behavior.
Boolean-Based Blind
The attacker asks true/false questions and observes whether the page changes. For example, a page that displays "Product found" or "Product not found" can be used to extract data one character at a time.
# Is the first character of the admin password 'a'?
https://shop.example.com/product?id=5 AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin') = 'a'
# If the page shows "Product found" → first character is 'a'
# If the page shows "Product not found" → first character is NOT 'a'
# Repeat for each character position and value
Time-Based Blind
When even the boolean response is not visible, attackers use time delays. If the injected condition is true, the database waits for a specified duration before responding.
# MySQL time-based blind injection:
https://shop.example.com/product?id=5 AND IF((SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin')='a', SLEEP(5), 0)
# If the response takes 5 seconds → first character is 'a'
# If the response is immediate → first character is NOT 'a'
Extracting data one character at a time is tedious for a human, but automated tools like sqlmap can extract entire databases through blind injection in minutes to hours, depending on the database size and response times.
Real-World SQL Injection Incidents
SQL injection has been behind some of the largest data breaches in history. These incidents demonstrate why prevention is critical.
- Heartland Payment Systems (2008) -- SQL injection was the initial entry point in a breach that exposed 130 million credit card numbers. The attackers used SQLi to install packet-sniffing malware on payment processing servers. It was the largest credit card breach at the time.
- Sony Pictures (2011) -- Attackers used a simple SQL injection to compromise databases containing personal information of over 77 million PlayStation Network accounts, including names, addresses, and credit card data. The breach cost Sony an estimated $171 million.
- TalkTalk (2015) -- A SQL injection attack on the UK telecom provider exposed personal data of 157,000 customers, including bank account details. TalkTalk was fined 400,000 pounds. The attack was carried out by teenagers using basic SQLi techniques.
- Equifax (2017) -- While the initial vulnerability was Apache Struts (not SQLi), the attackers used SQL injection techniques post-compromise to extract data from multiple databases, ultimately accessing records of 147 million people.
Many of these breaches did not require advanced skills or custom tools. Basic SQL injection attacks can be performed with nothing more than a web browser. The TalkTalk breach was carried out by a 15-year-old using freely available tools. Prevention is straightforward -- there is no excuse for shipping vulnerable code.
Prevention: Parameterized Queries
Parameterized queries (also called prepared statements) are the definitive solution to SQL injection. They work by separating the SQL code from the data, ensuring that user input is always treated as a value and never as executable SQL.
How They Work
Instead of concatenating user input into the query string, you define the query with placeholder parameters. The database driver sends the query structure and the data separately. The database engine compiles the query first, then binds the parameters as data values. It is structurally impossible for the data to alter the query logic.
Examples in Multiple Languages
# Python (with psycopg2 for PostgreSQL)
cursor.execute(
"SELECT * FROM users WHERE username = %s AND password = %s",
(username, password)
)
# Python (with SQLite3)
cursor.execute(
"SELECT * FROM users WHERE username = ? AND password = ?",
(username, password)
)
# PHP (with PDO)
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username AND password = :password");
$stmt->execute(['username' => $username, 'password' => $password]);
# Java (with JDBC)
PreparedStatement stmt = conn.prepareStatement(
"SELECT * FROM users WHERE username = ? AND password = ?"
);
stmt.setString(1, username);
stmt.setString(2, password);
ResultSet rs = stmt.executeQuery();
# Node.js (with mysql2)
const [rows] = await connection.execute(
'SELECT * FROM users WHERE username = ? AND password = ?',
[username, password]
);
When using parameterized queries correctly, the database engine never interprets
user input as SQL code. Even if an attacker enters ' OR '1'='1' --,
the database treats the entire string as a literal username value, not as SQL
syntax. The query returns no results because no user has that exact username.
Prevention: ORM Usage
Object-Relational Mappers (ORMs) provide an additional layer of abstraction over raw SQL. When used correctly, they automatically generate parameterized queries, making SQL injection nearly impossible by default.
ORM Examples
# Django ORM (Python) -- automatically parameterized
user = User.objects.filter(username=username, password=password).first()
# SQLAlchemy (Python) -- automatically parameterized
user = session.query(User).filter_by(username=username, password=password).first()
# Eloquent ORM (PHP/Laravel) -- automatically parameterized
$user = User::where('username', $username)->where('password', $password)->first();
# ActiveRecord (Ruby on Rails) -- automatically parameterized
user = User.find_by(username: username, password: password)
ORM Pitfalls
ORMs are safe by default, but most provide escape hatches for raw SQL. These escape hatches reintroduce the risk of SQL injection if used carelessly.
# DANGEROUS: Raw SQL in Django ORM
User.objects.raw("SELECT * FROM users WHERE username = '" + username + "'")
# SAFE: Raw SQL with parameters in Django ORM
User.objects.raw("SELECT * FROM users WHERE username = %s", [username])
# DANGEROUS: Raw SQL in SQLAlchemy
session.execute(text("SELECT * FROM users WHERE username = '" + username + "'"))
# SAFE: Raw SQL with parameters in SQLAlchemy
session.execute(text("SELECT * FROM users WHERE username = :name"), {"name": username})
Whenever you drop down to raw SQL within an ORM, you must use parameterized queries. The ORM's protection only applies to its query builder methods. Raw queries are your responsibility. Code review should flag any raw SQL that uses string concatenation or format strings with user input.
WAF Limitations and Defense in Depth
Web Application Firewalls (WAFs) are sometimes used to block SQL injection attempts by inspecting incoming HTTP requests for malicious patterns. While WAFs provide useful visibility and can catch basic attacks, they should never be your primary or only defense.
Why WAFs Are Not Enough
- Bypass techniques -- Attackers use encoding tricks (double URL encoding, Unicode substitution, comment injection) to evade WAF pattern matching. For every WAF rule, there are documented bypasses.
- False positives -- Aggressive WAF rules block legitimate requests. A user searching for "O'Brien" might trigger a SQL injection rule. This creates pressure to weaken the rules.
- No structural protection -- WAFs pattern-match on text. They cannot understand the SQL query structure or verify that parameterized queries are being used. They treat symptoms, not the disease.
- Maintenance burden -- WAF rules need constant updates as new bypass techniques are discovered. A set-and-forget WAF provides diminishing protection over time.
Defense in Depth Strategy
The correct approach is layered defense. No single measure is sufficient on its own.
# Example: Least-privilege MySQL user for a product catalog
CREATE USER 'catalog_reader'@'localhost' IDENTIFIED BY 'strong_random_password';
GRANT SELECT ON shop.products TO 'catalog_reader'@'localhost';
GRANT SELECT ON shop.categories TO 'catalog_reader'@'localhost';
-- This user CANNOT modify or delete data, even if SQLi is exploited
-- This user CANNOT access other databases or tables
Testing for SQL Injection
Identifying SQL injection vulnerabilities requires systematic testing of every input vector in the application. Here is a structured approach.
Manual Testing Steps
') into each input. If the application returns a database error
message, it is very likely vulnerable. Even a generic 500 error is suspicious.
id=5 AND 1=1 (should return normal results) versus
id=5 AND 1=2 (should return no results or different behavior).
If both behave differently, the input is being interpreted as SQL.
id=5 AND SLEEP(5) (MySQL) or
id=5; WAITFOR DELAY '0:0:5' (SQL Server). If the response is
delayed by 5 seconds, the application is vulnerable.
Automated Tools
- sqlmap -- The industry-standard open-source SQLi exploitation tool. It automates detection, exploitation, and data extraction across MySQL, PostgreSQL, SQL Server, Oracle, and SQLite. Use with explicit permission only.
- Burp Suite Scanner -- Professional web scanner with comprehensive SQLi detection. Identifies in-band, blind, and time-based vulnerabilities.
- OWASP ZAP -- Free open-source scanner with active SQL injection detection modules.
# sqlmap basic usage (only on systems you have permission to test)
sqlmap -u "https://example.com/product?id=5" --batch --level=3 --risk=2
# Options:
# -u Target URL with injectable parameter
# --batch Non-interactive mode (accepts defaults)
# --level=3 Thoroughness level (1-5)
# --risk=2 Risk of test payloads (1-3, higher = more aggressive)
# --dbs Enumerate databases after confirming injection
Only test for SQL injection on applications you own or have written authorization to test. Use practice environments like DVWA, WebGoat, HackTheBox, or PortSwigger Web Security Academy. Unauthorized testing is a criminal offense in most jurisdictions, regardless of whether you cause damage.
Now Do It Yourself: Five Steps
Reading about SQL injection convinces nobody; seeing your own login accept
' OR '1'='1 convinces you permanently. You will build a tiny database,
write a login the wrong way, break into it without a password, fix it with one
change, and then meet the one place the fix does not reach. Every line of output
below was produced by running this exact code — on Python 3.12 with the
built-in sqlite3, which behaves like every other SQL database for
what this teaches.
Go: open a terminal, make a folder you can write in with mkdir sqli-lab and cd sqli-lab, and open your text editor.
Do: save these eight lines as setup.py, then run python3 setup.py.
import sqlite3
db = sqlite3.connect("shop.db")
db.execute("CREATE TABLE users (id INTEGER, username TEXT, password TEXT, role TEXT)")
db.execute("INSERT INTO users VALUES (1, 'alice', 'correcthorse', 'admin')")
db.execute("INSERT INTO users VALUES (2, 'bob', 'hunter2', 'customer')")
db.execute("INSERT INTO users VALUES (3, 'carol', 'trustno1', 'auditor')")
db.commit()
print(db.execute("SELECT count(*) FROM users").fetchone()[0], "users created")
You should see: 3 users created, and a new file shop.db in the folder. That file is the database — no server to install, no service to start. Passwords are stored in plain text here only so the lesson stays about injection; never store a real password this way.
If not: if you run it a second time you get sqlite3.OperationalError: table users already exists — the table is already there from the first run. Delete shop.db and run it again to start clean. ModuleNotFoundError for sqlite3 is very rare, because it ships with Python; if you hit it, your Python was built without it, and reinstalling from python.org fixes it.
Go: same folder.
Do: save these nine lines as login.py, then run python3 login.py.
import sqlite3
username = "alice"
password = "correcthorse"
query = f"SELECT username, role FROM users WHERE username = '{username}' AND password = '{password}'"
print("SQL sent:", query)
db = sqlite3.connect("shop.db")
row = db.execute(query).fetchone()
print("Welcome,", row[0], "-", row[1]) if row else print("Login failed.")
You should see: Welcome, alice - admin, and above it the exact SQL it built. This is the whole bug in one line: the query is a string, and the username and password are glued straight into it. Right now the inputs are honest, so the query is honest. Printing the SQL is the trick that makes the next step obvious — watch that line, not the result.
If not: change password to "wrongpass" and run it again — you should get Login failed., because the query now matches no row. That confirms the login actually checks the password, which is exactly the thing you are about to defeat.
Go: same file.
Do: change only the password line to the payload below, keeping the quotes exactly, then run it again.
password = "anything' OR '1'='1"
You should see: Welcome, alice - admin — a successful admin login with a password that is not alice’s. Read the printed SQL line to see why: ... AND password = 'anything' OR '1'='1'. Your quote closed the password string early, and OR '1'='1' is a condition that is always true, so the whole WHERE is satisfied for the first row in the table. The database did exactly what it was told; the flaw is that your input got to write SQL.
If not: if you get Login failed., a quote is wrong — the payload needs a single quote right after anything and none around the whole thing when you type it into the file. Copy it character for character. If Python raises OperationalError: unrecognized token, you have an odd number of quotes; count them.
Go: same folder, new file.
Do: save these eleven lines as safe_login.py, leaving the password as the attack payload, and run python3 safe_login.py.
import sqlite3
username = "alice"
password = "anything' OR '1'='1"
db = sqlite3.connect("shop.db")
row = db.execute(
"SELECT username, role FROM users WHERE username = ? AND password = ?",
(username, password)
).fetchone()
print("Welcome,", row[0], "-", row[1]) if row else print("Login failed.")
You should see: Login failed. — the same payload that just broke in, refused. The ? marks are placeholders, and the database receives your input as a value to compare, never as SQL to run. So the payload is treated as one long, wrong password, which correctly matches nobody. Change password back to "correcthorse" and you get Welcome, alice - admin again: the real password still works. That is the entire fix — not filtering, not escaping, just never building the query out of the input.
If not: the commonest mistake is passing one argument like (username) or forgetting the trailing comma in a single-value tuple. The values go as a tuple whose length matches the number of ? marks — two here. If you get sqlite3.ProgrammingError: Incorrect number of bindings, count your ? against your tuple.
Go: same folder, new file.
Do: save these fifteen lines as sort.py, then run python3 sort.py role and afterwards python3 sort.py password.
import sqlite3, sys
column = sys.argv[1]
db = sqlite3.connect("shop.db")
print("-- with a placeholder:")
for r in db.execute("SELECT username, role FROM users ORDER BY ?", (column,)).fetchall():
print(" ", r[0], r[1])
allowed = {"username", "role"}
if column not in allowed:
print("-- '" + column + "' is not on the allow-list; falling back to username")
column = "username"
print("-- with an allow-listed name:")
for r in db.execute(f"SELECT username, role FROM users ORDER BY {column}").fetchall():
print(" ", r[0], r[1])
You should see: the placeholder block prints alice, bob, carol — the original insertion order, not sorted at all — and then the allow-listed block prints alice, carol, bob, genuinely sorted by role (admin, auditor, customer). Carol jumping up is the proof. That difference is the lesson: a placeholder can carry a value but never a column name, so ORDER BY ? silently sorts by nothing. The only safe way to put a name into a query is to check it against a list you wrote yourself, which is what the second half does.
If not: run python3 sort.py password and watch the fallback fire: 'password' is not on the allow-list; falling back to username. This is why you cannot “just parameterise everything” — table names, column names and ASC/DESC are not values, and for those an allow-list is the fix, never string-building from the raw input.
Without scrolling up: in step 3 the payload was
anything' OR '1'='1. Which single character does the actual
damage, and what does it do? Answer: the single quote. It closes the
password string early, so everything after it — OR '1'='1'
— is read as SQL logic instead of as part of the password. Placeholders
fix this because the input never gets to close the string.
Now do it without the page: the site also has a search
box that runs SELECT * FROM users WHERE username LIKE '%{term}%'.
Rewrite that query to use a placeholder. Watch where the % signs
go — they belong in the value you pass, as
f"%{term}%", not in the SQL string, so the query becomes
... LIKE ?.
Summary
SQL injection is one of the oldest and most damaging web application vulnerabilities. Here is what you learned:
- SQL injection occurs when user input is concatenated directly into SQL queries, allowing attackers to manipulate the query logic
- Classic SQLi returns results directly on the page; Union-based SQLi combines data from multiple tables; Blind SQLi extracts data through boolean or time-based inference
- Real-world SQLi attacks have compromised hundreds of millions of records and caused billions of dollars in damages
- Parameterized queries are the definitive prevention -- they structurally separate SQL code from user data, making injection impossible
- ORMs use parameterized queries by default, but raw SQL escape hatches must still be parameterized
- WAFs are a useful monitoring layer but cannot replace secure coding practices due to bypass techniques and false positives
- Defense in depth combines parameterized queries, input validation, least-privilege database accounts, proper error handling, and WAF monitoring
You now understand how SQL injection works and how to prevent it at every layer. Combined with your knowledge of XSS and security headers, you have a solid foundation in web application security. Continue exploring the OWASP Top 10 to learn about additional vulnerability classes.