SQL Injection Explained: Login Form Data Leak Fix
SQL injection turns a login form into a database dump.
20+ years shipping production backend systems. Lessons pulled from things that broke in production.
- ✓Basic SQL SELECT and WHERE syntax
- ✓Python functions and string formatting basics
- ✓How web forms send POST data
- SQL injection happens when user input is glued into a SQL string, so the database reads attacker text as code instead of data
- The classic login bypass types ' OR '1'='1 into a concatenated query, and the WHERE clause turns true for every row in the table
- Parameterized queries send the SQL template and the values separately, so bound input can never change the query structure
- ORMs block most cases but raw() calls, ordering strings, and table names still need allow-list checks before use
- A WAF is belt-and-suspenders only: it may slow attackers down, but it can't fix a query that trusts raw input
Think of a coffee shop where the barista reads your cup's name out loud. If you write your name plus an instruction like 'and give me everyone's order for free,' a careless barista might follow it. That's SQL injection. The app builds a database command by gluing your typed text straight into it, so sneaky text gets treated as an order. The fix is simple: the app fills in a form where your text can only ever be a name, never an order. Developers call the fix parameterized queries.
Login forms look harmless. Two boxes, one button, and a query you've written a hundred times: look up the user by email and check the password. But when that query is built with string concatenation, those two boxes become a direct line into your database. An attacker doesn't need your password or your source code. They just type SQL syntax into the form and let your own database run it.
This is SQL injection, and it has stayed near the top of the OWASP Top 10 for over a decade because the mistake is so easy to repeat. A f-string here, a plus operator there, and suddenly input controls logic. The result can be a full login bypass, dumped customer tables, or quietly modified prices and roles.
The good news is the core fix hasn't changed and it isn't complicated. Parameterized queries, also called prepared statements, keep code and data apart so input is always treated as a value. ORMs give you this protection for everyday queries, though raw SQL escapes and dynamic identifiers still need care. A web application firewall adds backup cover but never replaces the fix.
In this guide you'll see exactly how a concatenated login query breaks, how to rewrite it with bound parameters in Python, where ORMs still leave gaps, and how to layer defenses so one missed query doesn't become a breach.
How String Concatenation Turns Typing Into Database Code
Every SQL injection starts with one innocent line: a query string with user input glued inside it. In Python that looks like f"SELECT * FROM users WHERE email = '{email}'". When email holds a normal address, the database sees exactly what you expect. But the database parses whatever string it receives, and it has no idea which characters you wrote and which the visitor typed.
Type a single quote into that field and the string breaks open. The quote closes the intended value early, and everything after it becomes live SQL syntax. Training material demonstrates this with the minimal proof-of-concept string ' OR '1'='1, always shown here as motivation for parameterized fixes, because it rewrites the WHERE clause to be true for every row. The app then logs the attacker in as whoever the database returns first, usually an admin or the oldest account.
The same trick works anywhere input reaches a query: search boxes, sort parameters, password reset tokens, even HTTP headers your code logs to a table. Numeric fields aren't safe either, since an unquoted value like f"... WHERE id = {user_id}" needs no quote at all to inject extra logic. If input shapes the string, input shapes the query.
That's why the rule is absolute: never build SQL by gluing strings. The next sections show the replacement pattern, but the mental model matters most. Treat every query as two separate things, the code template and the data values, and never let the two mix in one string.
Parameterized Queries: the One Fix You Must Ship First
A parameterized query sends the SQL template and the values through separate channels. You write SELECT * FROM users WHERE email = ? and pass the email as a bound value. The database compiles the query structure first, then slots the value in as pure data. Quotes, dashes, and keywords inside the value lose all special meaning.
In Python's DB-API, every driver supports this with slightly different placeholders. sqlite3 and psycopg2 use %s or ?, mysql-connector uses %s, and SQLAlchemy uses named parameters. The shape is identical everywhere: placeholders in the SQL, values in a second argument, never string formatting. Use cursor.execute(sql, params) and let the driver handle quoting and escaping at the protocol level.
Prepared statements are the same idea with a performance bonus. The database parses and plans the template once, then reuses the plan for each execution with new values. Web apps that run the same login or lookup query thousands of times per minute get both safety and speed from this pattern.
Watch for two traps. First, placeholders can only stand in for values, not table names or keywords, so dynamic identifiers need a different defense covered later. Second, some drivers emulate prepares on the client by default, which can weaken guarantees; disable emulation so binding happens on the server. Ship parameterization first, then layer the rest.
ORM Safety Limits: raw(), extra(), and Dynamic Identifiers
Object-relational mappers protect you by default because their querysets use bound parameters under the hood. Filter calls like User.objects.filter(email=input) in Django or session.query(User).filter(User.email == input) in SQLAlchemy never glue input into SQL. For everyday CRUD work, staying inside the ORM is already the fix.
The danger lives in the escape hatches. Every ORM offers a way to drop down to raw SQL for complex reports, and those methods accept plain strings. Django's raw(), extra(), and RawSQL, SQLAlchemy's text() with glued values, and ActiveRecord's find_by_sql all become injection points the moment input touches the string. Code review should treat every raw call as guilty until proven parameterized.
Dynamic identifiers are the second gap. Column names, table names, and sort directions can't be bound parameters in any database, so ORDER BY {user_choice} is unsafe no matter which library you use. The defense is an allow-list: compare the input against a fixed tuple of known columns and fall back to a default when it doesn't match. Never quote-and-hope with identifiers.
Audit both gaps with two greps per release. One finds raw SQL methods, the other finds ordering and annotation calls that accept request data. Each hit gets either a parameterized rewrite or an allow-list check, plus a test that feeds it quote characters.
raw() or text() calls. Rule: review each raw call by hand every release; they're few enough to check individually.Least-Privilege Database Roles and Quiet Error Messages
Parameterization stops injection from changing query logic, but defense in depth assumes one query will still slip through. Least-privilege database roles shrink what that slipped query can do. If the web app only reads two tables, its database account should hold SELECT on exactly those tables and nothing else. No DROP, no DELETE, no access to billing or admin schemas.
Set this up with explicit GRANT statements per role instead of reusing a superuser account across services. The login service gets SELECT on users; the reporting job gets SELECT on orders; migrations run under a separate credential used only from CI. When credentials leak or a query breaks, the blast radius stays inside one small box.
Error messages need the same discipline. A raw database error returned to the browser teaches attackers your table names, column names, and database version. Log the full error server-side with a request ID, and show users a generic message like login failed. Tests should assert that quote-laden input produces the generic message, not a traceback.
Rotate credentials after any incident and after engineer departures. Store them in a secrets manager rather than environment files checked into repos. These steps don't prevent injection, but they decide whether a missed query costs you twelve rows or twelve tables.
WAF as Belt-and-Suspenders, Never the Main Fix
A web application firewall watches HTTP traffic and blocks requests that look like attacks. Managed rule sets from vendors like AWS or Cloudflare recognize classic injection strings and can stop casual probing before it reaches your app. During an active incident, flipping a WAF to blocking mode buys time while you ship the real fix.
But a WAF reads HTTP text, not your query structure, so it guesses. Attackers evade signatures with encoding tricks, comment placement, and database-specific syntax the rule set never considered. Every WAF tuning cycle trades false positives against missed attacks, and teams under pressure often switch to log-only mode, which is exactly when protection disappears.
Use the WAF as one layer among several. Keep blocking enabled on authentication and payment routes, alert on rule hits so someone actually reads them, and review bypass reports after each penetration test. Log full requests for forensics, but never store passwords or tokens in those logs.
The decision rule stays simple: if removing the WAF would leave you vulnerable, you're still vulnerable. Ship parameterized queries first, trim database grants second, and let the WAF absorb background noise third. That ordering survives audits and incidents alike.
Testing Gates That Keep Injection From Coming Back
One-off fixes fade; gates keep the codebase clean. Start with static analysis in CI: Bandit flags string-formatted SQL in Python, and Semgrep rules catch raw() calls with glued input across frameworks. These checks run in seconds and point at the exact line, which makes them easy to enforce as merge blockers.
Add behavior tests that feed hostile input through every query endpoint. Submit O'Brien, quote-dash sequences, and the minimal proof-of-concept string ' OR '1'='1 (used here only to verify the parameterized fix holds) into login, search, and sort fields, then assert the app returns normal results or a generic failure instead of errors or extra rows. Run these against a disposable test database so failures can't touch real data.
Schedule dynamic checks too. A yearly penetration test and a light DAST scan before big releases catch the paths unit tests miss, like headers or export jobs that reach SQL indirectly. Review WAF logs monthly for blocked attempts against your own forms; repeated hits on one endpoint mean it deserves a manual re-audit.
Finally, make the pattern visible in code review. A checklist item that asks does user input reach SQL as a bound value catches new mistakes while they're still cheap. Teams that combine the linter, the hostile-input test, and the checklist stop seeing this bug class entirely.
A Login Box Dumped 41,000 User Rows on a Friday Night
- An ORM protects only the queries that actually go through it. One hand-written query with glued input is enough for a full bypass, so audit raw SQL paths separately and test them with quote characters.
- A WAF in log-only mode is the same as no WAF during an incident. If you must tune false positives, do it with scoped rule exceptions, and keep blocking plus paging alerts on for authentication endpoints.
- Least privilege turns a disaster into an incident. A login role with SELECT on two tables can't drop tables or read unrelated schemas, which is what kept this breach to 41,000 rows instead of the whole database.
| File | Command / Code | Purpose |
|---|---|---|
| sqli_demo.py | con = sqlite3.connect(':memory:') | How String Concatenation Turns Typing Into Database Code |
| login_fixed.py | from hashlib import sha256 | Parameterized Queries |
| orm_safe.py | from sqlalchemy import create_engine, text | ORM Safety Limits |
| least_privilege.sql | CREATE ROLE app_login WITH LOGIN PASSWORD 'change-me-via-vault'; | Least-Privilege Database Roles and Quiet Error Messages |
| sqli_gate.sh | set -euo pipefail | Testing Gates That Keep Injection From Coming Back |
Key takeaways
raw() calls and dynamic identifiers need manual review and allow-lists.Common mistakes to avoid
5 patternsEscaping quotes instead of binding parameters
Assuming the ORM covers raw SQL helpers
raw() report leaks rows while queryset filters stay safe, and reviews miss it because most code looks clean.Binding values but gluing sort columns and table names
Running the WAF in log-only mode after false positives
Returning raw database errors to the browser
Interview Questions on This Topic
Why does the proof-of-concept string ' OR '1'='1 bypass a concatenated login query?
Frequently Asked Questions
20+ years shipping production backend systems. Lessons pulled from things that broke in production.
That's Injection. Mark it forged?
6 min read · try the examples if you haven't