Code Safari

Chapter 97·Intermediate·10 min read

SQL Injection Explained: The Bug That Still Empties Databases

What is SQL injection? A plain-English explanation of how attacker text becomes database commands, why string-concatenated queries are the root cause, and the one real fix — parameterised queries — plus why escaping and blacklists don't work.

July 26, 2026

The browser attacks put attacker text where the browser reads it as code. SQL injection is the same idea aimed one layer deeper: attacker text placed where the database reads it as code. It's among the oldest vulnerabilities on the OWASP Top 10, it has been the "how" behind some of history's largest breaches, and it is still being written into fresh code today — because the mistake that causes it feels so natural.

The mechanism: one string, two meanings

Your app needs to look up a user. You write what feels obvious — build the query by inserting the username into the SQL text:

query = "SELECT * FROM users WHERE username = '" + input + "'"

For a normal username alice, the database receives:

SELECT * FROM users WHERE username = 'alice'

Perfect. Now recall chapter 1's rule — the input field is a delivery mechanism. An attacker submits this as the "username":

' OR '1'='1

Glue that in and the database receives:

SELECT * FROM users WHERE username = '' OR '1'='1'

Read it as the database does: return rows where the username is empty OR where '1'='1' — which is always true. The query now returns every user. The attacker's text didn't sit inside the string value; their quote closed the string and their OR became part of your command. One string carried two meanings, and the database has no way to know which parts you meant as instructions and which arrived from a stranger.

Why it's a login bypass — and much worse

Point that at a login check:

SELECT * FROM users WHERE username = '<input>' AND password = '<input>'

A username of admin' -- turns the rest of the query — including the entire password check — into a comment (-- starts a SQL comment). The database sees SELECT * FROM users WHERE username = 'admin' and returns the admin row; you're logged in as admin having never known the password.

And that's just the front door. Because the attacker is now writing SQL against your database, injection scales all the way up:

  • UNION-based extraction — bolt a second query on to read other tables: ... UNION SELECT card_number, cvv FROM payments. Dump the users table, the password hashes, everything.
  • Blind injection — even when the app shows no query output, attackers infer data one bit at a time from whether the page errors or how long it takes (... AND SLEEP(5)). Slower, equally total.
  • Writes and worse — depending on permissions: modify balances, delete tables, or via database features reach the underlying OS.

The fixes that don't work

Because the visible trigger is a quote character, the tempting fixes all target the input. They all fail as a primary defence:

  • Escaping quotes by hand — double up every '. Misses numeric contexts (WHERE id = 5 needs no quotes, so nothing to escape — 5 OR 1=1 walks straight in), misses alternate encodings and Unicode tricks, and breaks the instant someone forgets it on one query out of hundreds.
  • Blacklisting keywords — ban OR, UNION, SELECT. Breaks legitimate data (nobody named Unio, nobody discussing "OR gates"), and attackers slip past with comments, casing, and encoding (UN/**/ION).
  • Stored-procedure faith — procedures help only if they themselves don't concatenate; a procedure that builds dynamic SQL from its arguments is just as injectable.

Every one of these tries to sanitise the data while leaving the fatal design — data and code sharing one string — in place. Chapter 3's lesson repeats exactly: filtering input is the wrong boundary.

The one real fix: parameterised queries

Stop building SQL by concatenation. Send the query and the data to the database as separate things — a query template with placeholders, and the values alongside it:

query  = "SELECT * FROM users WHERE username = ? AND password = ?"
execute(query, [input_username, input_password])

The database receives the command structure first, parses it, and locks in what's code. Then it accepts the values, treating them as pure data that fills the placeholders — never re-parsing them as SQL. Now the malicious ' OR '1'='1 is looked up as a literal username spelled with those exact characters. It matches no one. The attack doesn't fail because you filtered it; it fails because the database was never given the chance to interpret it as anything but a value.

Query template with ? placeholders
Database parses the structure
Values sent separately, as data
Placeholders filled — no re-parsing
Parameterisation: structure and data travel separately, so data can never become code.

This — also called prepared statements — is the fix. Not "a" fix among options; the fix. Escaping is a fragile patch over a broken design; parameterisation removes the design flaw. Everything else in this chapter is context for why nothing else is trustworthy.

The practical reality is friendly:

  • Every real database library supports it — placeholders (? or :name) are standard everywhere.
  • ORMs and query builders parameterise by default — Django's ORM, ActiveRecord, SQLAlchemy, Prisma, Hibernate all build parameterised queries under the hood. The danger is their raw-query escape hatches (.raw(), .query() with string interpolation) — the SQLi equivalent of XSS's dangerouslySetInnerHTML.
  • It's often faster — the database can cache the parsed plan of a reused template.

One caveat worth knowing: placeholders bind values, not identifiers. You cannot parameterise a table or column name (ORDER BY <user input>). When structure itself must be dynamic, validate the input against an explicit allowlist of known-good column names — never concatenate it.

Defence in depth around it

Parameterisation prevents SQLi. These limit the blast radius if something else goes wrong — layers, in the spirit of the final chapter:

  • Least-privilege database accounts — the app's login user shouldn't be able to DROP TABLE or read tables it never touches.
  • Don't leak raw database errors to users — verbose SQL errors hand attackers a map (see security misconfiguration).
  • A WAF may catch obvious payloads — a speed bump, never the fix.

Recap

  • SQL injection = attacker text placed where the database parses it as SQL, because the query and the input were concatenated into one string.
  • The classic ' OR '1'='1 closes your string literal and injects logic — bypassing logins and, via UNION/blind techniques, dumping whole tables.
  • It's catastrophic because it targets the database itself — SQLi breaches routinely mean the entire user table.
  • Escaping and keyword blacklists fail — numeric contexts, encodings, and edge cases leak; they also mangle real data.
  • Parameterised queries (prepared statements) are the fix: send structure and data separately so values can never be re-parsed as code — standard in every library, default in every ORM.
  • Bind values only; validate dynamic identifiers against an allowlist, and add least-privilege DB accounts as a blast-radius limiter.

Injection covered on both the browser and database side, we turn to the channel itself — how data is protected in transit, and what "the padlock" actually guarantees. Continue to HTTPS & TLS, explained.

SQL Injection Explained: The Bug That Still Empties Databases | Code Safari