What Is SQL Injection? How It Works and How to Prevent It

SQL injection is an attack where input typed into an application, such as a login form or a URL parameter, gets pasted into a database query and changes what the query does. It happens when code builds SQL by joining strings with user input, and it's prevented by passing input as bound parameters (placeholders) instead.

How SQL injection works: a login bypass

Here's the vulnerable pattern in Python with SQLite. The code builds the query with an f-string:

name = "alice"
pw = "' OR '1'='1"     # what the attacker types into the password box
q = f"SELECT id, name FROM users WHERE name = '{name}' AND password = '{pw}'"
print(q)
print(con.execute(q).fetchall())

Output from running this against a table with two users, alice and bob:

SELECT id, name FROM users WHERE name = 'alice' AND password = '' OR '1'='1'
[(1, 'alice'), (2, 'bob')]

Look at the printed query. The attacker's first quote closed the password string early, and the rest, OR '1'='1', became SQL. Because AND binds tighter than OR, the condition reads as "(name is alice and password is empty) or 1 equals 1". The second half is always true, so every row comes back. An app that logs in whoever matches the first row has just logged the attacker in as alice without a password.

MITRE's definition of the weakness (CWE-89) describes exactly this: the program builds an SQL command from external input without neutralising the characters that change its meaning, so the input is interpreted as SQL rather than as data.

Reading data with UNION

Bypassing a login is the mild version. If the query's results are shown on the page, an attacker can append a second query with UNION:

name = "x' UNION SELECT name, password FROM users --"
q = f"SELECT id, name FROM users WHERE name = '{name}'"

Output:

SELECT id, name FROM users WHERE name = 'x' UNION SELECT name, password FROM users --'
[('alice', 's3cret'), ('bob', 'hunter2')]

The -- starts an SQL comment, which swallows the leftover quote so the query stays valid. The page that was supposed to show one user's ID now lists every username with its password. (This also shows why passwords must be stored as salted hashes, never in plain text: then a leak like this exposes hashes rather than the passwords themselves.)

Depending on the database and the account's permissions, OWASP lists further consequences: modifying or deleting data, running administration operations such as shutting down the database server, reading files from the server's file system and, in some cases, issuing commands to the operating system.

The fix: parameterized queries

Keep the SQL fixed and pass values separately. The database driver sends the value as data, so it can never be read as SQL, whatever characters it contains:

con.execute(
    "SELECT id, name FROM users WHERE name = ? AND password = ?",
    (name, pw),
).fetchall()

Run with the same attack string, this returns []: the database looked for a user whose password is literally ' OR '1'='1 and found none. The Python documentation puts it plainly: "beware of using Python's string operations to assemble queries, as they are vulnerable to SQL injection attacks", and shows the same ? placeholder style.

Placeholder syntax differs by language and driver, but the idea is the same everywhere:

Language / librarySafe form
Python sqlite3cur.execute("... WHERE id = ?", (user_id,)) or named :id with a dict
Python psycopg (PostgreSQL)cur.execute("... WHERE id = %s", (user_id,))
Java JDBCPreparedStatement ps = conn.prepareStatement("... WHERE id = ?"); ps.setInt(1, userId);
PHP PDO$stmt = $pdo->prepare("... WHERE id = ?"); $stmt->execute([$id]);
Node.js (node-postgres)client.query("... WHERE id = $1", [userId])

The psycopg %s looks like Python string formatting but isn't: you pass the values as the second argument and the driver handles them. The psycopg docs warn never to use % or + to merge values into queries yourself; "... %s" % user_id is the vulnerable pattern again.

Why escaping and blocklists aren't enough

A common first instinct is to escape quotes or reject input containing ', -- or OR. OWASP's SQL Injection Prevention Cheat Sheet lists escaping only as a last resort it "strongly" discourages, because escaping rules depend on the database dialect and configuration. Blocklists fail in the other direction: they break legitimate input while missing encodings and syntax you didn't think of. A user called O'Brien can't sign up, and an attacker uses a different trick.

The O'Brien case is also a hint that a query is injectable. Concatenated SQL with an apostrophe in the input produces a syntax error. Real output from SQLite:

sqlite3.OperationalError: near "Brien": syntax error

If a search box or form throws a database syntax error on an apostrophe, the input is reaching the SQL parser. Attackers probe for exactly this.

When you can't use a placeholder: table names, columns, ORDER BY

Placeholders work for values only. As OWASP puts it, "parameter markers represent data values, not table names, column names, or SQL keywords." So a sortable table where the user picks the column and direction can't be parameterized. Use an allowlist: map the user's choice to a fixed string your code owns, and reject anything else.

SORT_COLUMNS = {"name": "name", "created": "created_at"}
column = SORT_COLUMNS.get(request_sort)
if column is None:
    raise ValueError("unknown sort column")
direction = "DESC" if request_dir == "desc" else "ASC"
sql = f"SELECT id, name FROM users ORDER BY {column} {direction}"

The f-string is safe here because every piece inserted into it comes from your own dictionary or literal, never from the request.

Defense in depth

Parameterized queries are the fix. These reduce the damage if one query slips through:

  • Least privilege. The application's database account should have only the permissions it needs. OWASP recommends never using an admin or DBA account for an application and using separate accounts for separate applications. A read-only reporting page shouldn't connect with an account that can DROP TABLE.
  • Don't show database errors to users. Log the details server-side and return a generic error. Error messages help attackers map your schema.
  • Validate input types early. If an ID must be an integer, convert it to one at the boundary and reject anything else. This doesn't replace parameters, but it narrows what can reach the query.
  • Use the ORM's query builder. ORMs bind parameters by default; review every use of their raw-SQL escape hatches.

How to find SQL injection in your own code

Search the codebase for SQL strings built by concatenation or formatting: f-strings, +, %, .format(), template literals or String.format next to keywords such as SELECT, WHERE, INSERT and ORDER BY. Each hit should either use placeholders or insert only values from an allowlist. Static analysis tools for most languages flag these patterns automatically, which is worth adding to CI so new cases are caught in review.

Frequently asked questions

Do ORMs prevent SQL injection?
Mostly, because ORM query methods bind values as parameters for you. The protection disappears when you use the ORM's raw SQL feature and build the string yourself, for example with an f-string inside a raw() or execute() call. Raw queries need placeholders just like hand-written SQL.
Is escaping quotes enough to prevent SQL injection?
No. Escaping rules depend on the database, its character set and its configuration, and one missed case reopens the hole. OWASP calls escaping a strongly discouraged last resort. Parameterized queries are the reliable fix because the value is never parsed as SQL at all.
Can stored procedures be vulnerable to SQL injection?
Yes. Calling a stored procedure with bound parameters is safe, but if the procedure itself concatenates its arguments into a dynamic SQL string and executes it, the injection happens inside the database. Dynamic SQL inside procedures needs parameter binding too.
Is SQL injection still common?
It is still listed among the most dangerous software weaknesses: MITRE includes CWE-89, SQL injection, in its 2025 CWE Top 25. Modern frameworks make the safe pattern the default, so new cases mostly come from hand-built query strings and legacy code.

Sources