SQL Injection: How Attackers Break Your Database and How Django's ORM Stops Them
Django Security Series — Post 1 | Series I: Injection Attacks
OWASP A03:2021 — Injection | Reading time: ~12 min
The Django Security Series is my ongoing project to document what I learn about application security — mapping the most dangerous web attacks to their concrete prevention in Python and Django. Every post is grounded in real incidents, real code, and the OWASP Top 10: I write to understand, and I publish so you can follow the same path. Attack techniques are mapped to MITRE ATT&CK tactics and techniques, prevention guidance is anchored to the official Django security documentation, and each post draws on two practitioner books: Web Security for Developers (Malcolm McDonald) and Secure Web Application Development with Python and Django (Matthew Baker, Apress).
SQL Injection is where we start. It has appeared in every edition of the OWASP Top 10 since the list was created in 2003, and in the 2021 report injection flaws were found in 94% of tested applications. Two decades of public breaches, frameworks, and education have not eliminated it — because the misunderstanding is not about ignorance of the attack, but about a false sense of security from using an ORM.
Django's ORM does protect you by default. But developers who reach outside it — through raw(), extra(), string formatting, or third-party integrations — open a gap that attackers have been reliably finding and exploiting for as long as web applications have existed. Understanding where the ORM's protection ends is as important as knowing that it exists.
By the end of this post you will know how SQL Injection works at the query level, why Django's parameterisation is structurally safer than any escaping approach, and exactly which coding patterns break that guarantee.
The Attack: What It Is and How It Works
SQL Injection occurs when an attacker inserts SQL syntax into an input field that a web application later embeds directly into a database query — without proper separation of code and data.
The root cause is deceptively simple: the developer treated user input as part of the program's logic. The database cannot tell the difference between the developer's intended SQL and the attacker's injected payload.
How It Works — Step by Step
Consider a login view that checks credentials like this:
SELECT * FROM users
WHERE username = '[USER INPUT]'
AND password = '[USER INPUT]'
An attacker enters admin' -- as the username. The single quote closes the string literal, and -- comments out the rest of the query. The database executes:
SELECT * FROM users
WHERE username = 'admin' --' AND password = ''
The password check is commented out. If a user named admin exists, the attacker is in — without knowing the password.
More advanced techniques:
| Technique | What it does |
|---|---|
| Tautology | ' OR '1'='1 — makes the WHERE clause always true |
| UNION injection | Appends a second SELECT to extract data from other tables |
| Blind SQLi (boolean) | Infers data with true/false questions: AND 1=1 -- vs AND 1=2 -- |
| Blind SQLi (time-based) | Uses pg_sleep() / SLEEP() to extract data via response delays |
| Stacked queries | Appends a second statement: '; DROP TABLE users; -- |
Real-World Incidents
Heartland Payment Systems (2008): SQL injection compromised the payment processing network of 250,000 businesses, exposing 130 million credit card numbers. Total cost: over $140 million in settlements. At the time, the largest data breach ever recorded.
TalkTalk (UK, 2015): A 15-year-old attacker used textbook SQL injection to steal records on 157,000 customers — including bank account details. The breach cost TalkTalk £77 million and a £400,000 fine from the ICO. The irony: the vulnerable database was running software from 2009.
Yahoo Voices (2012): The hacktivist group D33Ds Company extracted 450,000 plaintext user credentials via SQL injection and published them publicly, simultaneously demonstrating two failures: an injection vulnerability and weak password storage.
MITRE ATT&CK: T1190 (Exploit Public-Facing Application) covers the initial access. Common post-exploitation outcomes include T1005 (Data from Local System) and credential harvesting for lateral movement.
Django's Default Protections
The Django ORM parameterises every query it generates. This is the core protection.
When you write:
Post.objects.filter(title=user_input)
Django generates:
SELECT * FROM blog_post WHERE title = %s
…and passes user_input as a separate parameter to the database driver (psycopg3 for PostgreSQL, sqlite3 for SQLite). The driver transmits the query structure and data independently. The database processes user_input as a literal string value, never as SQL syntax.
A payload like ' OR '1'='1 is treated as the literal search string ' OR '1'='1 — not as SQL logic. The attack fails structurally, regardless of what characters the input contains.
This protection is not achieved through escaping or filtering. It works because the SQL and the data never share the same channel. Parameterisation is categorically stronger than any escaping approach.
Vulnerable Pattern: What NOT to Do
# INSECURE — Never build SQL with string formatting
from django.db import connection
def search_posts(request):
query = request.GET.get('q', '')
# DANGER: f-string embeds user input directly into SQL
sql = f"SELECT id, title, slug FROM blog_post WHERE title LIKE '%{query}%'"
with connection.cursor() as cursor:
cursor.execute(sql) # attacker controls the SQL structure
rows = cursor.fetchall()
return render(request, 'search.html', {'results': rows})
Attack payload: q=%' UNION SELECT id, password, email FROM auth_user LIMIT 10 --
The resulting query dumps up to 10 rows from the user table. The attacker now has hashed passwords and email addresses.
The same vulnerability appears in all of these:
# String concatenation — equally dangerous
sql = "SELECT * FROM blog_post WHERE slug = '" + slug + "'"
# %-string formatting — NOT parameterisation, still vulnerable
sql = "SELECT * FROM blog_post WHERE slug = '%s'" % slug
# .format() — also vulnerable
sql = "SELECT * FROM blog_post WHERE slug = '{}'".format(slug)
# ORM.extra() with unsanitised input — deprecated AND dangerous
Post.objects.extra(where=[f"title = '{user_input}'"])
# ORM.raw() with string formatting — dangerous
Post.objects.raw(f"SELECT * FROM blog_post WHERE slug = '{slug}'")
Note:
Post.objects.extra()has long been discouraged — the Django team describes it as an old API targeted for future deprecation, and no longer fixes bugs in it. If you still haveextra()calls in your codebase, this is the moment to remove them — Post 2 in this series covers the safe alternatives in depth.
Secure Implementation: The Django Way
Preferred: ORM Querysets
For the vast majority of use cases, the ORM handles everything safely:
# SECURE — ORM parameterises automatically
def search_posts(request):
query = request.GET.get('q', '').strip()
results = Post.objects.filter(
title__icontains=query,
publish=True,
language='en',
).order_by('-created')[:20]
return render(request, 'search.html', {'results': results})
icontains translates to ILIKE %s (case-insensitive; LIKE %s on databases without ILIKE support) with the value parameterised. Django adds the % wildcards at the ORM layer — the user input never touches the SQL string.
When Raw SQL Is Necessary: Parameterised Placeholders
Occasionally you need raw SQL — for complex queries, database-specific features, or full-text search. Always use %s placeholders:
# SECURE — raw SQL with parameterised placeholders
from django.db import connection
def full_text_search(query):
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT id, title, slug,
ts_rank(to_tsvector('english', body), plainto_tsquery(%s)) AS rank
FROM blog_post
WHERE to_tsvector('english', body) @@ plainto_tsquery(%s)
AND publish = TRUE
ORDER BY rank DESC
LIMIT 20
""",
[query, query] # ← parameters passed separately, never interpolated
)
return cursor.fetchall()
RawSQL() for ORM Annotations
# SECURE — user input passed via params list
from django.db.models.expressions import RawSQL
Post.objects.annotate(
rank=RawSQL(
"ts_rank(to_tsvector('english', body), plainto_tsquery(%s))",
[user_search_term] # ← parameterised
)
).filter(publish=True)
Rule Summary
| Do | Don't |
|---|---|
Post.objects.filter(title=value) |
f"SELECT ... WHERE title = '{value}'" |
cursor.execute("... WHERE id = %s", [value]) |
cursor.execute("... WHERE id = " + value) |
RawSQL("... %s ...", [value]) |
Post.objects.extra(where=[f"... '{value}'"]) |
Principle of Least Privilege
Parameterisation is your primary defence, but Malcolm McDonald makes an important point in Web Security for Developers (Chapter 6): defence in depth means securing every layer of the stack, not just the query construction.
For SQL injection specifically, that means the database account your Django application uses at runtime should have only the permissions it actually needs — typically SELECT, INSERT, UPDATE, and DELETE (the SQL DML subset). It should not have CREATE, DROP, or ALTER privileges (DDL). If an attacker somehow bypasses parameterisation, a least-privilege database account limits the blast radius: they cannot drop tables or modify schema, only operate within the data they can already reach.
In practice for a Django project:
-- PostgreSQL: create a restricted runtime user
CREATE USER blogtech_app WITH PASSWORD 'strongpassword';
GRANT CONNECT ON DATABASE blogtech TO blogtech_app;
GRANT USAGE ON SCHEMA public TO blogtech_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO blogtech_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO blogtech_app;
-- No CREATE, DROP, or ALTER granted
Run migrations with a separate privileged account; let the application use the restricted one at runtime.
Testing Your Defence
Unit Test
# blog/tests.py
from django.test import TestCase, Client
from blog.models import Post
class SQLInjectionTests(TestCase):
def setUp(self):
Post.objects.create(
title="Normal Post",
slug="normal-post",
body="Content",
publish=True,
language='en',
)
def test_tautology_payload_returns_no_match(self):
"""The injection string is treated as a literal — not SQL logic."""
payload = "' OR '1'='1"
self.assertEqual(Post.objects.filter(title=payload).count(), 0)
def test_union_payload_does_not_return_extra_rows(self):
"""UNION payload must not extract data from other tables."""
payload = "x' UNION SELECT id, title, slug FROM blog_post--"
self.assertEqual(Post.objects.filter(title__icontains=payload).count(), 0)
def test_search_view_handles_injection_safely(self):
"""HTTP 200 alone proves nothing — a vulnerable view also returns 200.
Verify the payload was treated as a literal string: results must be empty."""
client = Client()
response = client.get('/blog/', {'q': "' OR '1'='1"})
self.assertEqual(response.status_code, 200)
# If the tautology had succeeded, the DB would have returned all posts.
# Zero results confirms the input was never interpreted as SQL logic.
self.assertEqual(len(response.context['results']), 0)
Manual Verification
python manage.py shell
>>> from blog.models import Post
>>> Post.objects.filter(title="' OR '1'='1").count()
0 # correct: treated as a literal string, not SQL
Automated Scanning
SQLMap is the industry-standard tool for detecting SQL injection. Run it against your staging environment only:
# Basic scan of a search endpoint
sqlmap -u "https://staging.example.com/blog/?q=test" --level=2 --risk=1
# A properly parameterised Django app produces:
# [INFO] GET parameter 'q' does not appear to be injectable
SQL Injection is not a sophisticated attack — it is a predictable consequence of mixing data and code in the same string. Django's ORM removes that risk by design, but only as long as you stay within its parameterised interface. Every time you reach for an f-string, % formatting, or a raw string concatenation in a database query, you are stepping outside that guarantee. The rule is simple: SQL and user input must never share the same channel. Post 2 in this series looks at exactly where developers break that rule without realising it — in raw(), extra(), and third-party integrations that look safe but are not.
Further Reading
- Django Docs — SQL Injection Protection
- Django Docs — Executing raw queries (
cursor.execute,raw()) - Django Docs —
RawSQL()expression - OWASP A03:2021 — Injection
- OWASP SQL Injection Prevention Cheat Sheet
- PortSwigger Web Security Academy — SQL Injection
- SQLMap — Automatic SQL Injection Tool
- MITRE ATT&CK — T1190 Exploit Public-Facing Application
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Chapter 6: Injection Attacks
- Secure Web Application Development: A Hands-On Guide with Python and Django (Matthew Baker, Apress) — [relevant chapter]
Next in this series → Post 2: The Hidden Danger of raw() and extra(): Bypassing the ORM Safely with RawSQL()