Second-order SQL injection — also called 2nd order SQL injection, stored SQL injection, or persistent SQL injection — is one of the most frequently missed vulnerability classes in web application security assessments. Unlike classic SQL injection where the payload fires immediately, second-order SQLi stores a malicious payload in the database and triggers it later, in a completely different code path.
This complete guide covers how second-order SQL injection works, why standard scanners miss it, which SAST tools detect it, a step-by-step exploitation walkthrough, and the definitive fix in every major backend language.
What Is Second-Order SQL Injection?
Second-order SQL injection is a two-stage SQL injection attack:
-
Stage 1 — Storage: The attacker submits a malicious payload through a normal application input (registration form, profile update, API endpoint). The application may correctly sanitize the input for the storage query — so no injection occurs at this point. The malicious value is stored safely in the database.
-
Stage 2 — Execution: Later, the application retrieves the stored value and uses it to build a new SQL query — without re-sanitizing it. The developer trusts that data retrieved from their own database is already safe. This assumption is wrong. The SQL metacharacters in the stored value cause injection in the second query.
Classic SQL Injection vs. Second-Order SQL Injection
Classic (1st order) SQL injection:
HTTP Request → Input Parameter → SQL Query → Execution
↑ injection fires here
Second-order (2nd order) SQL injection:
Request A: Input → (safe storage) → Database
Request B: Database Read → SQL Query → Execution
↑ injection fires here, in a different request
The time gap between Request A and Request B can be seconds, hours, or weeks.
Why Second-Order SQL Injection Is So Dangerous
Several factors make second-order SQLi uniquely dangerous:
1. It bypasses input validation at the storage layer. The application may correctly escape or sanitize the input when storing it. The vulnerability is not in the storage query — it’s in the retrieval query. Many developers consider their application safe if the initial INSERT is parameterized.
2. It crosses code path boundaries. The storage code and the retrieval code are often written by different developers, in different files, possibly years apart. The developer who writes the retrieval code doesn’t know (or forgets) that the database value they’re using was originally user-supplied.
3. Standard DAST scanners can’t detect it. Dynamic scanners send a payload and immediately observe the HTTP response for signs of injection (error messages, timing delays, output differences). In second-order SQLi, the payload produces no immediate effect. The scanner would need to submit the payload in Request A, then trigger Request B and correlate the behavior — a multi-step flow that most DAST tools do not support unless specifically configured for it.
4. Pattern-matching SAST tools miss it. Simple static analysis tools look for user input flowing directly into a SQL query in the same code block. In second-order injection, the input arrives in one handler and fires in a completely separate one. The pattern-matcher sees two apparently safe code paths and reports no vulnerability.
Step-by-Step Second-Order SQL Injection Walkthrough
The Scenario: User Registration with Downstream Password Change
The application has two features:
- User registration (
POST /register) — saves the username to the database - Password change (
POST /change-password) — retrieves the stored username and updates the user’s password
Attacker’s goal: Change the password of another account (e.g., the admin user).
Step 1: Attacker Registers with a Malicious Username
The attacker registers a new account:
POST /register HTTP/1.1
Host: target-app.example.com
Content-Type: application/x-www-form-urlencoded
username=admin'--&password=attacker_password
The application escapes the username for the registration INSERT:
-- The single quote is escaped — no injection at this step
INSERT INTO users (username, password_hash)
VALUES ('admin''--', '$2b$12$hashedpassword...');
The application returns HTTP 200. No error. The malicious username admin'-- is now stored safely in the users table.
Step 2: Attacker Logs In
The attacker logs in with the registered credentials (admin'-- / attacker_password). The login is successful — the application correctly compares the stored username.
Step 3: Attacker Triggers the Password Change
The attacker changes their password using the application’s normal “change password” feature:
POST /change-password HTTP/1.1
Host: target-app.example.com
Cookie: session=attacker_session_token
Content-Type: application/x-www-form-urlencoded
new_password=hacked123
Step 4: The Payload Fires
The password change handler retrieves the attacker’s username from the session/database and builds an UPDATE query:
-- The application retrieves the stored username: admin'--
-- It trusts this data because "it came from our database"
-- It builds the UPDATE without re-parameterizing:
UPDATE users SET password_hash = '$2b$12$newhash...' WHERE username = 'admin'--'
The SQL comment -- terminates the query at the single quote before the WHERE clause is fully evaluated. The WHERE clause becomes:
WHERE username = 'admin'
-- the rest is commented out
Result: Every user named admin has their password changed to the attacker’s new password. The attacker now has admin access.
Real Code Examples: Vulnerable vs. Secure
Vulnerable PHP
// registration.php — Stage 1: escapes input for INSERT (appears safe)
$username = mysqli_real_escape_string($conn, $_POST['username']);
$sql = "INSERT INTO users (username, password_hash) VALUES ('$username', '$hash')";
mysqli_query($conn, $sql);
// Stores: admin'-- safely as a literal string
// password_change.php — Stage 2: retrieves from DB and TRUSTS it
$result = mysqli_query($conn, "SELECT username FROM users WHERE id=" . intval($_SESSION['user_id']));
$row = mysqli_fetch_assoc($result);
$username = $row['username']; // Contains: admin'-- (the SQL metacharacter is back)
// VULNERABLE: uses DB-sourced data in raw string concatenation
$sql = "UPDATE users SET password_hash='$new_hash' WHERE username='$username'";
// ^^^^^^^^^^^^^^
// Executes: WHERE username='admin'-- (comments out rest of query)
mysqli_query($conn, $sql);
The fix: Parameterize every SQL query, including those using data retrieved from the database.
// password_change.php — FIXED
$stmt = $conn->prepare("SELECT username FROM users WHERE id = ?");
$stmt->bind_param("i", $_SESSION['user_id']);
$stmt->execute();
$username = $stmt->get_result()->fetch_assoc()['username'];
// SECURE: DB-sourced data treated as untrusted — parameterized
$update = $conn->prepare("UPDATE users SET password_hash = ? WHERE username = ?");
$update->bind_param("ss", $new_hash, $username);
$update->execute();
Vulnerable Java
// RegistrationServlet.java — Stage 1: parameterized INSERT (safe)
PreparedStatement insert = conn.prepareStatement(
"INSERT INTO users (username, email) VALUES (?, ?)"
);
insert.setString(1, username);
insert.setString(2, email);
insert.execute();
// PasswordChangeServlet.java — Stage 2: retrieves and uses unsafely
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT username FROM users WHERE id = " + userId);
rs.next();
String storedUsername = rs.getString("username"); // Tainted: admin'--
// VULNERABLE: string concatenation with DB-sourced data
Statement update = conn.createStatement();
update.execute(
"UPDATE users SET password_hash = '" + newHash + "' WHERE username = '" + storedUsername + "'"
);
Fix:
// PasswordChangeServlet.java — FIXED
PreparedStatement select = conn.prepareStatement(
"SELECT username FROM users WHERE id = ?"
);
select.setInt(1, userId);
ResultSet rs = select.executeQuery();
rs.next();
String storedUsername = rs.getString("username");
// SECURE: PreparedStatement — storedUsername treated as parameter, not SQL
PreparedStatement update = conn.prepareStatement(
"UPDATE users SET password_hash = ? WHERE username = ?"
);
update.setString(1, newHash);
update.setString(2, storedUsername); // DB value still gets parameterized
update.executeUpdate();
Vulnerable Python
# registration_view.py — Stage 1: parameterized (safe)
cursor.execute(
"INSERT INTO users (username, email) VALUES (%s, %s)",
(username, email)
)
# password_change_view.py — Stage 2: retrieves and uses unsafely
row = cursor.execute(
"SELECT username FROM users WHERE id = %s", (user_id,)
).fetchone()
stored_username = row[0] # Contains: admin'--
# VULNERABLE: f-string with DB-sourced data
cursor.execute(
f"UPDATE users SET password_hash = '{new_hash}' WHERE username = '{stored_username}'"
)
Fix:
# password_change_view.py — FIXED
row = cursor.execute(
"SELECT username FROM users WHERE id = %s", (user_id,)
).fetchone()
stored_username = row[0]
# SECURE: parameterized regardless of data source
cursor.execute(
"UPDATE users SET password_hash = %s WHERE username = %s",
(new_hash, stored_username) # DB value parameterized
)
Vulnerable C#
// RegistrationController.cs — Stage 1: safe
using var insert = new SqlCommand(
"INSERT INTO Users (Username, Email) VALUES (@username, @email)", conn
);
insert.Parameters.AddWithValue("@username", username);
insert.Parameters.AddWithValue("@email", email);
insert.ExecuteNonQuery();
// PasswordController.cs — Stage 2: retrieves and uses unsafely
using var select = new SqlCommand(
"SELECT Username FROM Users WHERE Id = @id", conn
);
select.Parameters.AddWithValue("@id", userId);
string storedUsername = (string)select.ExecuteScalar(); // admin'--
// VULNERABLE: string interpolation with DB-sourced value
using var update = new SqlCommand(
$"UPDATE Users SET PasswordHash = '{newHash}' WHERE Username = '{storedUsername}'", conn
);
update.ExecuteNonQuery();
Fix:
// PasswordController.cs — FIXED
using var select = new SqlCommand(
"SELECT Username FROM Users WHERE Id = @id", conn
);
select.Parameters.AddWithValue("@id", userId);
string storedUsername = (string)select.ExecuteScalar();
// SECURE: parameterized — DB-sourced value treated as untrusted input
using var update = new SqlCommand(
"UPDATE Users SET PasswordHash = @hash WHERE Username = @username", conn
);
update.Parameters.AddWithValue("@hash", newHash);
update.Parameters.AddWithValue("@username", storedUsername);
update.ExecuteNonQuery();
Which SAST Tools Detect Second-Order SQL Injection?
Detecting second-order SQL injection requires interprocedural taint analysis — the ability to trace a data value from where it enters the application, through a database write, through a database read, and into a subsequent SQL query, across multiple function and file boundaries.
This is significantly more complex than detecting first-order SQLi, which only requires seeing user input reach a SQL query in the same or adjacent code paths. Here is how major SAST tools approach second-order detection:
Checkmarx
Checkmarx CxSAST and Checkmarx One include dedicated second-order SQL injection detection:
- CxSAST query:
SQL_Injection_Second_Order - Checkmarx One query:
Second_Order_SQL_Injection
Checkmarx models database write operations as taint propagators (not sanitizers). The taint flows: HTTP input → INSERT query → database storage → SELECT query → retrieved value → new SQL query. The finding shows the full cross-file, cross-request data flow path.
Common Checkmarx false positives for second-order SQLi:
- Retrieved values passed through a parameterized query at the sink (Checkmarx may not recognize all ORM parameterization patterns)
- Retrieved values validated against a strict allowlist before use
- ORM-generated queries (Hibernate, Entity Framework) where Checkmarx doesn’t fully model the parameterization
If you have a Checkmarx SQL_Injection_Second_Order false positive, verify that the sink query uses a PreparedStatement, @Parameter, ORM LINQ query, or parameterized ORM method, then document it with the specific sanitizer name.
Fortify SCA
Fortify Static Code Analyzer detects second-order SQL injection through its interprocedural taint engine. Findings appear under the SQL Injection: Second Order category. Fortify’s Audit Workbench shows the full source-to-sink trace including the database intermediary step.
Fortify’s second-order detection covers Java, C#/.NET, PHP, and Python. Coverage is generally considered strong for Java enterprise applications.
Offensive360
Offensive360 SAST performs deep interprocedural taint analysis that models database storage as a taint propagation channel. The finding includes:
[HIGH] Second-Order SQL Injection
Source: RegistrationController.php:45 — $_POST['username']
Storage: RegistrationController.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 taint trace spans two files and two request handlers — the complete attack chain. Offensive360’s detection covers PHP, Java, Python, C#, JavaScript/TypeScript, Ruby, Go, and 55+ other languages.
Tools with Limited Second-Order Detection
Most pattern-matching SAST tools — basic SonarQube Community Edition, Semgrep without custom rules, most linter-based security scanners — do not detect second-order SQL injection. They lack the interprocedural taint analysis required to trace data across the database boundary.
DAST scanners typically miss second-order SQLi unless they are specifically configured for multi-step testing workflows. A standard DAST scan submits a payload and observes the immediate response — it does not correlate stored payloads with later execution.
Common Second-Order SQL Injection Scenarios
Beyond the username/password-change scenario, second-order SQLi appears in several recurring patterns:
1. Email Address in Password Reset
Storage: User registers with email x'OR'1'='[email protected]
Trigger: Password reset flow retrieves email and uses it in a query without parameterization
# Trigger: password reset handler
email = get_email_from_reset_token(token) # Returns attacker-controlled value from DB
cursor.execute(
"SELECT id FROM users WHERE email = '" + email + "'"
)
2. Profile Fields in Reporting Queries
Storage: User stores '; DROP TABLE orders;-- in their “city” profile field
Trigger: Admin reporting feature builds a WHERE clause using stored city values
-- Admin report query using stored city from user profile
SELECT * FROM orders WHERE city = ''; DROP TABLE orders;--'
3. Username in Audit Logging
Storage: User registers as admin'--
Trigger: Audit log query filters by stored username
// Audit log search — trusts DB-sourced username
stmt.execute("SELECT * FROM audit_log WHERE actor = '" + storedUsername + "'");
4. Dynamic SQL in Stored Procedures
Stored procedures that build dynamic SQL using retrieved values are particularly common second-order injection sites:
-- VULNERABLE stored procedure
CREATE PROCEDURE sp_UpdateUserProfile (@UserId INT, @NewBio NVARCHAR(500))
AS BEGIN
DECLARE @Username NVARCHAR(100)
SELECT @Username = username FROM Users WHERE id = @UserId
-- Dynamic SQL built with DB-sourced @Username
DECLARE @sql NVARCHAR(500)
SET @sql = N'UPDATE Profiles SET bio = ''' + @NewBio + ''' WHERE username = ''' + @Username + ''''
EXEC sp_executesql @sql -- INJECTION: @Username from DB is not parameterized
END
Fix:
-- FIXED stored procedure
CREATE PROCEDURE sp_UpdateUserProfile (@UserId INT, @NewBio NVARCHAR(500))
AS BEGIN
DECLARE @Username NVARCHAR(100)
SELECT @Username = username FROM Users WHERE id = @UserId
-- Parameterized dynamic SQL
DECLARE @sql NVARCHAR(500)
SET @sql = N'UPDATE Profiles SET bio = @bio WHERE username = @uname'
EXEC sp_executesql @sql,
N'@bio NVARCHAR(500), @uname NVARCHAR(100)',
@bio = @NewBio, @uname = @Username -- Both values parameterized
END
How to Find Second-Order SQL Injection in Your Codebase
Manual Code Review Strategy
Second-order SQL injection leaves a specific pattern: data flows from a database read into a SQL query construction. Search for this pattern:
- Find all database read operations:
fetch_assoc(),getString(),fetchone(),scalar(),.FirstOrDefault(),executeQuery(), etc. - For each retrieved value, trace where it is used in the function
- Flag any retrieved value used in string concatenation that feeds into a SQL query
- Cross-file review: search for the column names retrieved in step 1 and check how the values are used across the entire codebase
# Find SQL queries that use string concatenation (PHP example)
grep -rn '\$sql.*\.' src/ | grep -v "prepare\|bind_param"
# Find Java methods that concatenate strings into executeQuery/execute
grep -rn "executeQuery\|executeUpdate\|execute\b" src/ | grep '"'
# Python: find execute calls without parameterization placeholders
grep -rn "cursor.execute\|conn.execute" src/ | grep -v "%s\|?\|:param\|f\"\|format\|%"
Automated Detection with SAST
The most reliable approach for finding second-order SQL injection in a large codebase is using a SAST tool with interprocedural taint analysis. Tools that model the database as a taint propagation channel — Checkmarx, Fortify, and Offensive360 — will surface these paths automatically, including cross-file and cross-module paths that code review often misses.
Black-Box Testing
If you only have access to the running application (not source code), test for second-order SQLi manually:
- Register accounts with SQL metacharacters in every profile field:
test'--,test' OR '1'='1,test'; SELECT SLEEP(3);-- - Log in and trigger every function that uses your stored profile data: change password, update profile, view order history, generate reports, export data, admin panel
- Observe behavior: SQL errors, unexpected data returned, timing differences, behavioral changes
- Check if other users’ data appears: if the application returns data from other accounts after you trigger a function, that confirms second-order injection
Prevention Checklist
- Parameterize every SQL query — no exceptions, including queries that use data retrieved from the database, session, or cache
- Use an ORM for all database operations — Hibernate, Entity Framework, Django ORM, ActiveRecord, and SQLAlchemy all parameterize by default; audit any
.raw(),FromSqlRaw(),execute(), ornativeQuerycalls within the ORM - Code review multi-step data flows — when any data is written to the database and later retrieved for use in a query, a reviewer must explicitly verify parameterization at the second query
- SAST with interprocedural taint analysis — run a scanner that models database reads as taint sources; verify second-order SQLi is in the vendor’s supported vulnerability list before purchasing
- Least-privilege database accounts — the application’s database user should not have
DROP,ALTER,GRANT, or cross-schema access; limits blast radius if exploitation occurs - Database stored procedure review — audit any stored procedures that build dynamic SQL using
EXECorsp_executesqlwith string concatenation
OWASP and CWE Classification
Second-order SQL injection is a distinct subclass within the OWASP Injection category:
| 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 (includes second-order testing) |
| CVSS Base Score | Typically 7.5–9.8 (High to Critical) depending on data access and impact |
The OWASP Web Security Testing Guide explicitly calls out second-order injection as requiring a different testing methodology from first-order: the tester must register a payload, then trigger all downstream operations that could use the stored value, observing for deferred execution.
Summary
Second-order SQL injection exploits one of the most common assumptions in application development: that data retrieved from your own database can be trusted in SQL queries. This assumption is false.
| First-Order SQL Injection | Second-Order SQL Injection | |
|---|---|---|
| Payload execution | Immediate | Deferred — fires in a later query |
| Standard DAST detection | Usually detected | Usually missed |
| Pattern-matching SAST | Usually detected | Usually missed |
| Taint-analysis SAST | Detected | Detected (with DB taint modeling) |
| Root cause | Unsanitized input in SQL query | Trusted DB data in SQL query |
| Fix | Parameterized query at input | Parameterized query at every SQL call |
The prevention is absolute and simple: parameterize every SQL query, at every execution point, regardless of where the data came from. Whether the value came from an HTTP request parameter, a session cookie, or a SELECT query from your own database makes no difference. Parameterization is the only reliable defense.
Offensive360 SAST detects second-order SQL injection through interprocedural taint analysis that models database read and write operations as taint propagation channels. Scan your codebase for second-order SQLi and the full OWASP Top 10 — one-time scan from $500, results within 48 hours. Or book a demo to see a live second-order injection walkthrough on a vulnerable application.