Overview
SQL injection happens when untrusted input is concatenated directly into a SQL query string, letting an attacker change the query's meaning, turning a login check into an always-true condition, or appending a second statement that drops a table. It remains one of the most common serious vulnerabilities because the naive fix (string concatenation) looks like it works right up until someone sends input designed to break out of it. The real fix is structural: keep user input and SQL syntax in separate channels entirely, via parameterized queries, so there is no string to break out of.
Quick Reference
| Approach | Safe? | Why |
|---|---|---|
| String concatenation | No | User input becomes part of the SQL text itself |
| Manual escaping | Risky | Easy to miss a context (identifiers, LIKE patterns, numeric fields) |
| Parameterized queries / prepared statements | Yes | Input is bound as data, never parsed as SQL syntax |
| ORM query builder methods | Yes (for generated queries) | Parameterized internally; raw-SQL escape hatches still need care |
| Allowlisting for identifiers (table/column names) | Yes | Identifiers cannot be parameterized, must be validated against a fixed set |
Syntax
// VULNERABLE - user input concatenated directly into the query
const query = `SELECT * FROM users WHERE email = '${email}'`
// email = "' OR '1'='1" turns this into an always-true condition
// SAFE - parameterized query, input sent separately from the SQL text
const result = await db.query('SELECT * FROM users WHERE email = $1', [email])Never trust client-side escaping
Client-side validation and JavaScript-based escaping can always be bypassed by an attacker calling your API directly; they're a UX nicety, not a security boundary. The server must parameterize independently of anything the client claims to have sanitized.
Examples
# Python (psycopg2) - parameterized, safe
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
# Never do this - f-string interpolation into raw SQL
cursor.execute(f"SELECT * FROM users WHERE email = '{email}'") # vulnerable-- Identifiers (table/column names) cannot be parameterized at all;
-- they must be validated against a fixed allowlist, never taken from
-- raw user input, even with an otherwise-parameterized query.
-- e.g. sortColumn must be checked against ['name', 'created_at'] in code
-- before being interpolated into: ORDER BY {sortColumn}Visual Diagram
Common Mistakes
- Relying on manual escaping or blocklisting specific characters instead of parameterized queries; it is fragile and context-dependent.
- Assuming an ORM makes an application immune, while still using its raw-SQL escape hatch with string interpolation somewhere.
- Trying to parameterize a table or column name, parameter binding only works for values, not identifiers; those need allowlisting instead.
- Building
LIKEpatterns orIN (...)lists via string concatenation instead of using the driver's parameter-array support. - Trusting client-side validation as a security boundary; it can always be bypassed; the server must validate and parameterize independently.
Performance
- Prepared statements can be reused across executions with different parameter values, which lets the database cache the query plan, parameterized queries are often faster, not just safer, under repeated execution.
- Parameter binding adds negligible overhead compared to string concatenation; there is no meaningful performance reason to avoid it.
Best Practices
- Use parameterized queries or an ORM's query builder for every query that includes user-supplied values, no exceptions for "just this one internal tool."
- Allowlist (never blocklist) any user-influenced identifier, like a sort column or table name, against a fixed set of valid values.
- Apply least-privilege database accounts per service, so even a successful injection has limited blast radius.
- Run static analysis or a linter rule that flags raw string interpolation into SQL, catching the mistake before it reaches production.