How does SQL injection work?
SQL injection happens when an application builds a query by pasting user input into SQL text. The database cannot tell which part of the string the developer wrote and which part the user sent, so input that contains SQL syntax becomes part of the command.
A search endpoint that builds its query with string concatenation:
// Vulnerable: userId and q are pasted into the SQL text
const rows = await db.query(
`SELECT id, name FROM projects WHERE owner_id = ${userId} AND name LIKE '%${q}%'`
);A single quote in q closes the string literal early. Anything after it is parsed as SQL. The simplest proof is a pair of requests whose only difference is a condition that is always true versus always false:
GET /api/projects?q=demo%27%20AND%20%271%27%3D%271 HTTP/1.1
Host: app.example
Cookie: session=user_aHTTP/1.1 200 OK
Content-Type: application/json
{"results": [{"id": "project_1043", "name": "demo-project"}]}The same request with '1'='2 returns an empty list. If the input were treated as data, both requests would search for a project literally named with those quotes and return nothing. A result that flips with the condition proves the input is being executed.
What does SQL injection look like in practice?
The variants differ in how the attacker sees the result, which drives how a tester confirms them.
- In-band. The query's output or error comes back in the response. Error-based injection shows a database error message; UNION-based injection appends a second SELECT whose rows appear in the page. Fast to confirm, loud in logs.
- Blind, boolean-based. The response never shows query output, but it changes when a condition is true or false: a result appears or not, a status code differs, a page length shifts. The attacker extracts data one yes-or-no question at a time.
- Blind, time-based. Nothing in the response changes, so the tester makes the database pause (
pg_sleep(5)on PostgreSQL,SLEEP(5)on MySQL,WAITFOR DELAYon SQL Server) and measures the response time. - Out-of-band. The database is made to send a DNS or HTTP request to a host the tester controls. Rare, depends on database features and egress rules.
- Second-order. The payload is stored safely on the way in, then used unsafely later. A username saved as
demo'--through a parameterized signup form breaks a nightly report job or an admin screen that builds its own query from stored values. Scanners that test one request at a time miss this almost entirely.
Where it hides in ORM code
ORMs parameterize by default, so modern SQL injection lives in the escape hatches:
| Stack | Safe | Injectable |
|---|---|---|
| Prisma | $queryRaw tagged template | $queryRawUnsafe with string input, Prisma.raw(userInput) |
| Django | filter(), raw(sql, params) | raw(f-string), extra(where=...), cursor.execute with % formatting |
| Rails | where(name: v), where("name = ?", v) | where("name = '#{v}'"), order(Arel.sql(params[:sort])) |
| Sequelize | replacements, bind parameters | sequelize.query with template literals |
| Any | bound values | ORDER BY, column names, table names from input |
The last row matters most. Identifiers and keywords cannot be bound as parameters, so sort columns, sort directions, and dynamic table names are the most common injection points in codebases that otherwise use an ORM correctly.
The best-known recent case is CVE-2023-34362 in Progress MOVEit Transfer, an unauthenticated SQL injection in the web application. NVD records it as CWE-89 and it was added to the Known Exploited Vulnerabilities catalog on June 2, 2023. CISA's advisory AA23-158A reports that the CL0P group began exploiting it on May 27, 2023, and used it to install a web shell named LEMURLOOT and take data from the underlying databases.
How do you test for SQL injection?
The goal is to prove the database is parsing your input, with the smallest possible change to the query.
- Map every input that reaches a query. Query strings, JSON bodies, headers, cookies, GraphQL arguments, and especially sort, order, filter, and column-selection parameters. Include values that are stored and reused later.
- Break the syntax. Send a single quote, a double quote, and a backslash. A 500 error, a database error string, or a changed result set on one of them is a lead, not a finding.
- Confirm with a boolean pair. Send a true condition and a false condition that should produce identical results if the input is data. For numeric parameters use
1043 AND 1=1versus1043 AND 1=2. A consistent difference across repeated requests confirms injection. - Fall back to timing. If responses never change, append a conditional delay for each likely database engine and compare timings over several runs to rule out network jitter.
- Check sort and identifier parameters separately. Try
sort=nameversussort=(SELECT 1)or an invalid column; an error that names the column, or a change in order, shows the value reaches SQL. - Trace second-order paths. Store a quote-bearing value in a profile, filename, or tag, then trigger every feature that reads it back: exports, reports, admin views, background jobs.
- Stop at proof. A confirmed boolean or timing difference is enough for a report. Automated tools such as sqlmap can confirm and fingerprint the engine; run them only within the agreed scope.
Dynamic testing finds the in-band and simple blind cases. The ORM escape hatches and second-order paths are usually found faster in secure code review by grepping for raw-query methods and string-built SQL.
How do you fix SQL injection?
Use parameterized queries everywhere a value enters SQL, and use an allowlist everywhere an identifier does. Parameterization sends the query text and the values to the database separately, so input can never change the query's structure.
Node with Prisma, including a sort column that cannot be bound:
import { Prisma } from "@prisma/client";
const SORTABLE = { name: "name", created: "created_at" } as const;
export async function searchProjects(ownerId: string, q: string, sort: string) {
const column = SORTABLE[sort as keyof typeof SORTABLE] ?? "created_at";
return prisma.$queryRaw`
SELECT id, name FROM projects
WHERE owner_id = ${ownerId} AND name ILIKE ${"%" + q + "%"}
ORDER BY ${Prisma.raw(column)}
`;
}Prisma.raw is only safe here because column comes from a fixed map, never from the request.
Django, with the ORM first and a raw query second:
from django.db import connection
# ORM: parameterized automatically
Project.objects.filter(owner_id=owner_id, name__icontains=q)
# Raw SQL: pass values as params, never format them into the string
with connection.cursor() as cur:
cur.execute(
"SELECT id, name FROM projects WHERE owner_id = %s AND name ILIKE %s",
[owner_id, f"%{q}%"],
)Go with database/sql:
rows, err := db.QueryContext(ctx,
"SELECT id, name FROM projects WHERE owner_id = $1 AND name ILIKE $2",
ownerID, "%"+q+"%")Then limit the damage: connect with a database role that has only the permissions the application needs, with no rights to system tables or file and command functions.
What does not work:
- Escaping quotes by hand. Escaping rules differ by database, character set, and context, and they do nothing for numeric parameters or identifiers, which have no quotes to escape.
- A WAF. It matches known payload shapes. Encodings, comments, and engine-specific syntax get past signatures, and second-order payloads arrive through a request that looks harmless. A WAF buys time while the query is fixed.
- Input blocklists. Rejecting words like
SELECTorORbreaks legitimate input and misses equivalent syntax. - Stored procedures alone. A procedure that builds dynamic SQL from its arguments is injectable in the same way.
Static analysis rules that flag raw-query methods receiving non-literal strings catch most regressions before they ship.
SQL injection vs other injection
SQL injection is one member of the injection family grouped under A03:2021 in the OWASP Top 10. NoSQL injection abuses query operators in document databases (a JSON object such as {"$ne": null} where a string was expected), command injection reaches a shell, and cross-site scripting injects into HTML and JavaScript in the victim's browser. The root cause is the same in all of them: data and code sharing one channel.
[ Sources ]
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command
- OWASP Top 10 2021: A03 Injection
- OWASP SQL Injection Prevention Cheat Sheet
- PortSwigger Web Security Academy: Blind SQL injection
- Prisma docs: Raw queries and SQL injection
- CISA advisory AA23-158A: CL0P exploitation of MOVEit CVE-2023-34362
Written by Parameter · Last reviewed

