Back to glossary

SQL Injection

SQL injection is a vulnerability where untrusted input is parsed as SQL code, letting an attacker read or change data the app never meant to expose.

Also known as SQLi, SQL injection attack, CWE-89

SQL injection (SQLi) is a vulnerability where untrusted input is parsed as part of a SQL command instead of as data. Attackers use it to change what a query does: bypass a login, dump tables, rewrite records, or sometimes run commands on the database host. It exists wherever query text and user values reach the parser as one string.

SQL injection is old and still near the top of the weakness rankings. MITRE's 2025 CWE Top 25 ranks SQL injection (CWE-89) second, behind only cross-site scripting. It ranked third in MITRE's 2024 CWE Top 25. Our position is that the fix is structural, not a matter of better filtering. Values must reach the database through a channel the SQL parser never reads, and libraries must stop deriving query structure from runtime values. The SQL injection bugs we find most instructive in 2026 are not string concatenation in application code. They live in drivers and ORMs that turn a JSON object or a dictionary into SQL syntax, and some of them have never received a CVE.

How does SQL injection work?

#

SQL injection works because the application writes a small program at runtime and hands it to an interpreter that cannot tell which bytes the developer wrote. A database receives a query as text. Its lexer splits the text into tokens: keywords, identifiers, operators, and string literals delimited by single quotes. The parser then builds a tree from those tokens. Nothing in that pipeline records where a byte came from.

JavaScript
const sql = "SELECT id FROM accounts WHERE username = '" + username +
            "' AND pw = '" + hash(password) + "'";
db.query(sql);

For the username alice, the lexer sees one string literal and two conditions. For the username admin' -- , the attacker's quote closes the literal early, -- starts a line comment, and the password check disappears:

SQL
SELECT id FROM accounts WHERE username = 'admin' -- ' AND pw = '9f8c…'

In the intended query, the input occupies exactly one token, the string literal 'alice', and the WHERE clause keeps both conditions joined by AND. In the injected query, the input spans two tokens: the literal 'admin' and a comment that starts at -- and swallows the rest of the line, including AND pw = '9f8c…'. The parse tree has one condition, so the database returns the admin row without checking a password.

SQL injection belongs to a larger family. Code injection, cross-site scripting, and prompt injection share its root cause: control and data travel in one channel, and the interpreter separates them by syntax alone. MITRE's Common Weakness Enumeration names the weakness exactly: "Improper Neutralization of Special Elements used in an SQL Command."

What can an attacker do with SQL injection?

#

An attacker with SQL injection can do anything the application's database account can do. The techniques are usually grouped by how results come back to the attacker:

  • In-band. The injected query's output appears in the response. UNION-based injection appends UNION SELECT to read rows from other tables. Error-based injection leaks values through database error messages.
  • Blind. Nothing comes back, so the attacker asks yes-or-no questions. Boolean-based blind injection watches whether the page changes. Time-based blind injection calls SLEEP(5) or pg_sleep(5) and measures latency.
  • Out-of-band. The database is induced to make a DNS or HTTP request that carries the data out.
  • Second-order. The payload is stored safely, then concatenated into a different query later, often by a background job that trusts its own database.

Whether an attacker can append a second statement, the classic ; DROP TABLE, depends on the driver and protocol, not just the database. PostgreSQL's simple query protocol runs several semicolon-separated statements from one message. Its extended query protocol rejects a Parse message containing more than one statement, per the protocol documentation.

Escalation beyond the data depends on privileges. PostgreSQL's COPY ... TO PROGRAM runs a shell command on the server, but only for superusers or members of pg_execute_server_program. An application that connects as a superuser turns every SQL injection into remote code execution. The principle of least privilege is the cheapest blast-radius control available.

MOVEit Transfer is the canonical recent case. According to CISA advisory AA23-158A, the CL0P ransomware gang began exploiting a SQL injection vulnerability in the file-transfer product, CVE-2023-34362, on May 27, 2023. The attackers installed a web shell named LEMURLOOT and stole data from organizations running the software. On March 25, 2024, CISA and the FBI issued a Secure by Design alert aimed at software makers, which notes: "Vulnerabilities like SQLi have been considered by others an 'unforgivable' vulnerability since at least 2007."

Why do parameterized queries stop SQL injection?

#

Parameterized queries stop SQL injection because the query text and the values reach the database in separate protocol messages, and only the text is ever parsed as SQL. PostgreSQL's wire protocol makes this explicit. In the extended query protocol, a Parse message carries SQL text with $1-style placeholders, and a later Bind message carries the values. The PostgreSQL documentation describes the feature as "supplying data values as separate parameters instead of having to insert them directly into a query string." MySQL does the same with the COM_STMT_PREPARE and COM_STMT_EXECUTE commands.

We captured the MySQL path against MariaDB 10.11 with the general query log enabled. The Node.js driver mysql2 3.24.6, called through execute(), prepares the statement once and then sends values separately:

Plain text
Prepare  SELECT id, username FROM accounts WHERE username = ? AND password = ?
Execute  SELECT id, username FROM accounts WHERE username = 'admin' AND password = 'hunter2'

The Execute line is the server's reconstruction for its log. On the wire, hunter2 arrived as a typed parameter in a binary COM_STMT_EXECUTE packet, after the statement had already been parsed. No byte in it can become a keyword.

The trap is that a ? in your code does not guarantee a server-side prepared statement. The mysql package's query() and mysql2's query() substitute placeholders on the client. They escape each value, splice it into the SQL text, and send one plain query. That is escaping with nicer syntax.

The figure contrasts the two paths for the same login query. With client-side interpolation, the driver sends one COM_QUERY packet whose text already contains the value it built from user input, in this case password = `password` = 1, and the server lexes and parses all of it, so the value's shape becomes SQL syntax and a row comes back. With a server-side prepared statement, the driver first sends COM_STMT_PREPARE with ? placeholders, the server parses it and returns a statement ID with 2 parameter slots, and then the driver sends COM_STMT_EXECUTE with the values 'admin' and '{"password":1}' as parameters. The server binds them without lexing, and no rows match.

Escaping is safe only if the escaper and the server's lexer agree on every byte. CVE-2025-1094 shows what happens when they do not. PostgreSQL's libpq quoting functions, including PQescapeLiteral() and PQescapeStringConn(), failed to neutralize quoting syntax in text that failed encoding validation, allowing SQL injection when the result was fed to psql (PostgreSQL advisory). Rapid7 found that every exploit it tested for BeyondTrust's CVE-2024-12356 "had to include exploitation of CVE-2025-1094" (Rapid7). PostgreSQL shipped fixes on February 13, 2025, in versions 17.3, 16.7, 15.11, 14.16, and 13.19.

Placeholders also cannot bind everything. A parameter is a value, so it cannot stand in for a table name, a column name, a sort direction, or a keyword. Code that builds ORDER BY ${req.query.sort} needs an allowlist that maps user choices to fixed identifiers.

How do you prevent SQL injection attacks?

#

You prevent SQL injection attacks by making it impossible for input to reach the SQL parser as text. That means server-side parameterized queries for values, allowlists for identifiers, and strict type checks where untrusted input enters the application. Everything else reduces impact or catches mistakes.

DefenseWhat it stopsWhat it missesVerdict
Server-side prepared statementsInjection through any bound valueIdentifiers, sort keys, and SQL text assembled before preparingDefault for every query that takes a value
Client-side placeholder interpolationQuote breakout in ordinary string valuesEscaper bugs, encoding mismatches, objects and arrays expanded into SQLAcceptable with a maintained driver and strict input types. Not equivalent to server-side binding
ORM or query builderMost hand-written concatenationRaw-SQL escape hatches and query-shape injection through objects or keyword argumentsUse one, then audit every raw query and every place user input becomes keys
Input types and allowlistsInjection through identifiers, and objects arriving where strings were expectedFree-text fields that legitimately contain quotesRequired for identifiers and for type checks. Never the only control
Least-privilege database accountEscalation to files, shell commands, and other schemasReading or changing whatever the account can already reachAlways. It bounds the blast radius
Web application firewallCommodity payloads such as ' OR 1=1Encodings, JSON bodies, blind and second-order techniquesDetection and short-term virtual patching only

Our verdict: use server-side binding for values and allowlists for identifiers, enforce types at the boundary, and run the application with a database account that cannot do much. A WAF buys time during an incident. It does not fix SQL injection.

SQL injection inside your dependencies

#

SQL injection inside a dependency happens when a driver or ORM converts structured input into SQL syntax. The application passes what it believes is a value, and the library treats the value's shape as instructions. The bug sits in a transitive dependency or in a documented contract nobody read, so a review of the application code finds nothing.

The clearest example is the mysql package for Node.js. Its README states that objects passed as values "are turned into key = 'val' pairs." Express's JSON body parser produces objects from attacker-controlled JSON. Put the two together:

JavaScript
app.post('/login', express.json(), (req, res) => {
  db.query(
    'SELECT id, username FROM accounts WHERE username = ? AND password = ?',
    [req.body.username, req.body.password],
    (err, rows) => res.json({ ok: rows.length > 0 })
  );
});
JSON
{"username": "admin", "password": {"password": 1}}

We ran this against MariaDB 10.11 with mysql 2.18.1. The driver generated this query, and it returned the admin row:

SQL
SELECT id, username FROM accounts WHERE username = 'admin' AND password = `password` = 1

MySQL and MariaDB evaluate password = `password` first. That comparison checks the column against itself and yields 1, and 1 = 1 is true. Every value was escaped. The injection came from the value's type. The same payload also works through a URL on Express 4, whose default query parser turns ?password[password]=1 into a nested object. We confirmed that on Express 4.22.3; Express 5.3.0 leaves it as a flat string key.

Flatt Security described this technique in February 2022. As of November 2026, mysql 2.18.1, published January 23, 2020, is still the package's latest release, and npm audit reports 0 vulnerabilities for it. mysql2 kept the same default through 3.16.3. Version 3.17.0, published February 11, 2026, replaced its escaping library with sql-escaper, which stringifies objects outside SET clauses. We ran the identical call against both:

Plain text
mysql2 3.16.3  ... WHERE username = 'admin' AND password = `password` = 1
mysql2 3.17.0  ... WHERE username = 'admin' AND password = '[object Object]'

Neither version carries a security advisory for this behavior, so a version-matching scanner flags nothing in either direction.

Django's CVE-2025-64459 is the Python form of the same mistake. QuerySet.filter(**kwargs) accepted a private _connector keyword argument that Django placed between WHERE conditions. List endpoints that filter on whatever query parameters arrive let the client choose the connector. On Django 5.2.7 with SQLite:

Python
params = {"username": "alice", "is_staff": "False", "_connector": "OR 1=1 OR"}
Account.objects.filter(**params)
# WHERE (NOT "demo_account"."is_staff" OR 1=1 OR "demo_account"."username" = %s)
# returns ['alice', 'root']

The query returned every account, including the staff account. Django 5.2.8 raises TypeError: The following kwargs are invalid: '_connector'. Django fixed the bug on November 5, 2025, in 5.2.8, 5.1.14, and 4.2.26 and rated it high (Django security release). Sequelize's CVE-2023-25813, rated CVSS 10.0 by GitHub, is a third variant: combining replacements with a where option let attacker values be interpreted as SQL until version 6.19.1.

Each of these bugs lets input decide query structure through a side door: an object's keys, a dictionary's keyword names, a second substitution pass. The defense that works across all driver versions is a type check at the edge. A login handler that rejects non-string passwords is immune to the mysql variant regardless of which release is installed:

JavaScript
if (typeof req.body.username !== 'string' || typeof req.body.password !== 'string') {
  return res.sendStatus(400);
}

With the mysql package, also set stringifyObjects: true on the connection, which formats the same object as '[object Object]'. Schema validation on every request body generalizes the type check. Never pass a request's parsed query or body straight into a query builder's keyword arguments.

Why CVE scanners see only part of SQL injection risk

#

Version-matching scanners see SQL injection in dependencies only after someone files an advisory, and only at the package level. That fails in two directions.

The first failure is silence. The mysql object expansion has never received a CVE, so npm audit and other CVE-driven scanners report the package clean. Finding that class of bug requires knowing the driver's contract and tracing where untrusted input flows, which is the job of taint-tracking static analysis on your own code. The CVE feeds are also thinning: since April 2026, NIST no longer routinely enriches most CVEs with the CVSS and CPE data that automated tools match on.

The second failure is noise. The Sequelize CVE needs replacements and where in the same call, so an application that never combines them is not exploitable through it. Reachability analysis answers that question with a call graph from application code into the vulnerable function. Socket's Tier 1 reachability works at that function level and, per our announcement, typically filters out about 80% of vulnerabilities as unreachable. Reachability cannot fix the first failure, because a documented feature has no vulnerable function to reach.

SQL injection is also a bug class, not malware, and the distinction matters for the data layer. A malicious package needs no injection at all. The shanhai666 NuGet packages that our Threat Research Team found shipped working repository-pattern code for SQL Server, PostgreSQL, and SQLite. Hidden inside was a roughly 20-line payload that kills the host process with 20% probability on each query after trigger dates in 2027 and 2028. No prepared statement, WAF, or SQL injection scanner stops code you installed yourself.

The string-concatenation form of SQL injection has had a known fix for decades, and CISA's alert is right to repeat the word unforgivable. What remains is harder to see: libraries that read the type or shape of a runtime value as permission to emit SQL. We think drivers and ORMs should treat that behavior as a vulnerability, not a feature, and publish an advisory when they change it. mysql2's 2026 change was the right call. The mysql package, still clean in CVE feeds, ships the old default today.