2nd order SQL injection (also written as second-order SQL injection, stored SQL injection, or persistent SQL injection) is a vulnerability where the attacker’s payload is stored safely in the database, then fired in a completely different query later — bypassing the security checks that protect the initial write.
Understanding the deferred execution pattern is the key to detecting and preventing it. This guide walks through how 2nd order SQL injection works mechanically, why most scanners fail to catch it, and exactly how to find and fix it in your codebase.
How 2nd Order SQL Injection Differs from Classic SQLi
Classic SQL injection fires immediately: the attacker submits a payload in a request, the server embeds it in a SQL query on the same request, and the database executes the malicious SQL right then.
2nd order SQL injection is deferred:
| Step | Classic SQLi | 2nd Order SQLi |
|---|---|---|
| 1 | Attacker submits payload | Attacker submits payload |
| 2 | App builds SQL with payload | App stores payload safely in DB |
| 3 | DB executes malicious SQL | — (nothing yet) |
| 4 | — | Later request triggers a new query |
| 5 | — | App retrieves stored value, builds new SQL |
| 6 | — | DB executes malicious SQL |
The gap between steps 2 and 5 can be seconds, hours, or weeks. The payload sits dormant in the database until the application performs an operation that retrieves it and uses it unsafely in a new query.
The Deferred Payload Pattern
Here is the minimal example of the 2nd order SQLi pattern:
Request A: POST /register
username = "admin'--"
→ App escapes it → stored as: admin'-- in users.username
--- some time passes ---
Request B: POST /change-password
→ App retrieves username from DB: "admin'--"
→ App builds: UPDATE users SET password='x' WHERE username='admin'--'
→ The -- comments out the WHERE clause
→ ALL users' passwords are updated
Two separate requests. Two separate developers may have written the code. The person who wrote the password change function trusted that data retrieved from their own database was safe — a reasonable but incorrect assumption.
Why Most Scanners Miss 2nd Order SQLi
DAST scanners
Dynamic scanners send a payload and immediately inspect the response for SQL error messages, time delays (blind SQLi), or output differences. In 2nd order SQLi, the payload triggers on a different request than the one that stored it. The scanner sees no immediate SQL error on Request A, so it marks the input as safe.
Even if the scanner later sends Request B (triggering the vulnerability), it has no memory of the payload it injected in Request A, so it doesn’t correlate the two.
Simple SAST (pattern-matching) scanners
Basic static analysis tools look for patterns like "SELECT ... WHERE " + variable. They find this at the initial input point (if present) but do not trace data through the database. When the query uses a value retrieved from the database rather than directly from HTTP input, pattern-matching scanners mark it as “trusted database data” and skip it.
What’s needed: inter-procedural taint analysis
The only static analysis approach that reliably detects 2nd order SQLi is inter-procedural taint analysis with database modeling:
- Mark HTTP input as “tainted”
- When tainted data is written to the database, mark the DB as holding tainted values
- When data is read from the DB, mark the retrieved variable as tainted
- Trace the tainted variable through the second code path to its SQL query sink
- Report a finding if the tainted data reaches the sink without parameterization
This requires the SAST engine to model the database as a taint-propagating intermediary — a capability only present in enterprise-grade tools like Checkmarx, Fortify, and Offensive360.
Real Code Examples
Example 1: PHP — Username in Profile Update
<?php
// ─── Stage 1: Registration (appears safe) ────────────────────────────────
// Attacker registers username: admin'--
$username = mysqli_real_escape_string($conn, $_POST['username']);
$stmt = $conn->prepare("INSERT INTO users (username, password_hash) VALUES (?, ?)");
$stmt->bind_param("ss", $username, $password_hash);
$stmt->execute();
// Stored: admin'-- in the database. No injection here.
// ─── Stage 2: Password Change (vulnerable) ───────────────────────────────
// Different file, different developer, different HTTP request
$result = $conn->query("SELECT username FROM users WHERE id = " . (int)$_SESSION['user_id']);
$row = $result->fetch_assoc();
$username = $row['username']; // ← Retrieved from DB: "admin'--"
// Developer trusts $username because "it came from our database"
$sql = "UPDATE users SET password_hash = '$new_hash' WHERE username = '$username'";
// ↑ VULNERABLE: admin'-- terminates the WHERE clause
// Executes: UPDATE users SET password_hash = '...' WHERE username = 'admin'--'
// Updates ALL rows, not just this user
$conn->query($sql);
Fix: Parameterize the second query too.
// FIXED
$stmt = $conn->prepare("UPDATE users SET password_hash = ? WHERE username = ?");
$stmt->bind_param("ss", $new_hash, $username); // DB-sourced data still gets parameterized
$stmt->execute();
Example 2: Python — Email in Password Reset
import sqlite3
# ─── Stage 1: Registration ────────────────────────────────────────────────
# Attacker registers with email: x' OR '1'='1
cursor.execute(
"INSERT INTO users (email, display_name) VALUES (?, ?)",
(email, display_name) # Parameterized — safe storage
)
# ─── Stage 2: Password Reset ──────────────────────────────────────────────
# Triggered by a password reset link click (different request entirely)
reset_token = request.args.get('token')
email = get_email_from_token(reset_token) # Returns tainted email from DB
# Developer assumes email from DB is trusted
cursor.execute(
"SELECT id FROM users WHERE email = '" + email + "'"
# ^^^^^^^^^^^
# VULNERABLE: x' OR '1'='1 → returns all users
)
user_row = cursor.fetchone() # Returns first row (likely an admin account)
reset_password_for_user(user_row['id'], new_password)
# Attacker has now reset an arbitrary account's password
Fix:
# FIXED — always parameterize, regardless of data source
cursor.execute("SELECT id FROM users WHERE email = ?", (email,))
Example 3: Java — Display Name in Search
// Stage 1: User registration — display name stored safely
String displayName = request.getParameter("displayName");
PreparedStatement insert = conn.prepareStatement(
"INSERT INTO users (display_name, email) VALUES (?, ?)"
);
insert.setString(1, displayName);
insert.setString(2, email);
insert.execute();
// Stage 2: Admin search feature (different servlet)
// Attacker's stored display name: ' UNION SELECT username, password_hash FROM admins--
ResultSet userResult = conn.createStatement().executeQuery(
"SELECT id FROM users WHERE id = " + userId
);
String displayName = userResult.getString("display_name"); // Tainted from DB
// Vulnerable query construction trusting DB-sourced value
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
"SELECT * FROM audit_log WHERE actor_name = '" + displayName + "'"
// ^^^^^^^^^^^^^^^^
// UNION SELECT query appended — attacker reads from admins table
);
Fix:
// FIXED — PreparedStatement for the second query
PreparedStatement auditQuery = conn.prepareStatement(
"SELECT * FROM audit_log WHERE actor_name = ?"
);
auditQuery.setString(1, displayName); // displayName treated as untrusted even from DB
ResultSet rs = auditQuery.executeQuery();
Example 4: SQL Server — Stored Procedure Vulnerability
Dynamic SQL inside stored procedures is a particularly common 2nd order SQLi location, because developers assume stored procedures are inherently safe:
-- VULNERABLE stored procedure
CREATE PROCEDURE sp_GetUserAuditLog
@UserId INT
AS
BEGIN
DECLARE @Username NVARCHAR(100)
-- Retrieve username from DB (could be attacker-controlled)
SELECT @Username = username FROM Users WHERE id = @UserId
-- VULNERABLE: dynamic SQL built with untrusted DB data
DECLARE @Query NVARCHAR(500)
SET @Query = N'SELECT * FROM AuditLog WHERE username = ''' + @Username + ''''
EXEC sp_executesql @Query
END
-- If @Username = admin'-- (stored by attacker), returns all audit log rows
Fix:
-- FIXED — parameterize the dynamic SQL
CREATE PROCEDURE sp_GetUserAuditLog
@UserId INT
AS
BEGIN
DECLARE @Username NVARCHAR(100)
SELECT @Username = username FROM Users WHERE id = @UserId
-- Parameterized dynamic SQL
DECLARE @Query NVARCHAR(500)
SET @Query = N'SELECT * FROM AuditLog WHERE username = @uname'
EXEC sp_executesql @Query, N'@uname NVARCHAR(100)', @uname = @Username
END
The Core Mental Model: The Database Is Not a Sanitizer
The root cause of every 2nd order SQLi vulnerability is a false assumption: data from our own database is safe to use in SQL queries.
This assumption is wrong for two reasons:
-
Attackers control what goes into the database. Registration forms, profile update endpoints, comment sections, message fields — all of these write attacker-controlled data into your database. Whatever escaping happened at write time is irrelevant to what you do with that data at read time.
-
Context changes between write and read. The escaping that made
admin'--safe for an INSERT query (a string literal) does not make it safe for a WHERE clause in a different query. SQL injection is context-dependent.
The fix is simple and absolute: parameterize every query that uses any database value, without exception. Data origin (HTTP request vs. database vs. session) is irrelevant — the only thing that matters is whether the value is inserted into the query as a parameter or as string concatenation.
How to Find 2nd Order SQLi in Your Codebase
Manual code review approach
Search for this pattern across your codebase:
- Find all places where data is read from the database:
fetchone(),getString(),fetch_assoc(),executeQuery(),scalar(),.FirstOrDefault(), etc. - For each retrieved value, trace where it’s used
- Flag any usage in string concatenation that feeds into a SQL query
Grep targets (adapt for your language):
# Python
grep -n "execute\s*(" **/*.py | grep -v "?" | grep -v "execute_values"
# Java — look for string concat near executeQuery
grep -n "executeQuery\|execute\|prepareStatement" **/*.java | grep '"'
# PHP
grep -n 'query\s*(\|execute\s*(' **/*.php | grep '\.'
SAST detection
Use a SAST tool capable of inter-procedural taint analysis. In Offensive360, 2nd order SQLi appears as a high-severity finding showing the full data flow:
[HIGH] Second-Order SQL Injection
Source: UserRegistrationController.php:45 — $_POST['username']
Storage: UserRegistrationController.php:52 — INSERT INTO users
Retrieval: PasswordChangeController.php:23 — SELECT username FROM users
Sink: PasswordChangeController.php:31 — $conn->query("UPDATE ... '$username'")
CWE: CWE-89
The path spans two files and two controllers — exactly the kind of cross-context vulnerability that pattern-matching tools cannot detect.
Manual black-box testing
To test for 2nd order SQLi without source code access:
- Register an account with username:
test'--ortest' OR '1'='1 - Log in with that account
- Trigger every operation that uses your account details: password change, profile update, search, activity logs, email notifications
- Watch for SQL errors, unexpected behavior, or data from other accounts appearing
- Also check any admin features that reference your account
Prevention Checklist
- Parameterize every SQL query — no exceptions for database-sourced data
- Use an ORM by default — Django ORM, Hibernate, ActiveRecord, Entity Framework all parameterize automatically; audit any
.raw()ornativeQuerycalls - Code review focus: any time data is retrieved from the database and used in a new query, a reviewer must verify parameterization at the second query
- Run a SAST tool with taint analysis — tools must model database reads as taint sources; ask vendors specifically about their second-order SQLi detection before purchasing
- Least-privilege database accounts — if exploitation occurs, the blast radius is limited if the DB user cannot DROP, ALTER, or access tables outside the application schema
- Audit stored procedures for dynamic SQL construction using
EXECorsp_executesqlwith string concatenation
Mapping to Standards
| Standard | Reference |
|---|---|
| CWE | CWE-89: Improper Neutralization of Special Elements used in an SQL Command |
| OWASP Top 10 | A03:2021 — Injection |
| OWASP Testing Guide | WSTG-INPV-05: Testing for SQL Injection |
| CVSS | Typically High (7.5–9.8 depending on impact) |
| PCI DSS | Requirement 6.3.1 — Protect against known vulnerabilities |
Summary
2nd order SQL injection exploits the false trust developers place in their own database data. The payload is stored safely, then triggered later in a completely separate query — making it invisible to most DAST scanners and simple SAST pattern matchers.
The prevention is the same as for first-order SQLi: parameterized queries everywhere — including queries where the values come from your own database, session, or cache. Never treat data as trusted simply because it originated from an internal source.
If you’re working through a Checkmarx SQL_Injection_Second_Order or Offensive360 “Second-Order SQL Injection” finding, the remediation path is always the same: find the second query (the sink in the tool’s data flow path) and convert it to use a prepared statement or parameterized ORM call.
Scan your codebase for second-order SQL injection and the full OWASP Top 10 with Offensive360 SAST. Deep inter-procedural taint analysis across 60+ languages — see results in under 10 minutes.