Second-order SQL injection is a variant of SQL injection where the attacker’s payload is stored in the database first and executed later — in a completely separate request. The delay between injection and execution is what makes it so dangerous: the application appears safe at the point where input is received, and the vulnerability only fires in a different code path that a different developer may have written.
This guide walks through concrete second-order SQL injection examples in PHP, Java, Python, and C#, explains exactly why standard scanners miss it, and covers the definitive fix.
What Is Second-Order SQL Injection?
In classic (first-order) SQL injection, user input is immediately incorporated into a SQL query:
-- Classic injection: fires on the same request
SELECT * FROM users WHERE name = 'alice' OR '1'='1'--'
In second-order SQL injection, the attack is split across two separate operations:
Stage 1 — Storage: The attacker submits a malicious payload through a registration form, profile update, or any input field. The application may sanitize or escape the input for the storage query — so no injection occurs at this point.
Stage 2 — Execution: Later — perhaps seconds, perhaps days later — the stored value is retrieved from the database and used to build a new SQL query. This second query does not re-sanitize the retrieved value, because the developer assumed that data coming from their own database is already safe.
That assumption is wrong. That is the vulnerability.
Second-Order SQL Injection: Step-by-Step Example
Step 1: Attacker Registers a Malicious Username
The attacker registers an account with the username admin'--:
POST /register HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=admin'--&password=attacker_password
The application escapes the username for the INSERT:
-- The single quote is escaped — no injection here
INSERT INTO users (username, password_hash)
VALUES ('admin''--', '$2b$12$Kj...');
The escaped string admin'-- is stored safely as a literal value. Zero vulnerabilities so far.
Step 2: Victim Uses “Change Password” Feature
Days later, an administrator or the attacker logs in and triggers the “change password” function. The application retrieves the username from the session (or from the database) and builds a new query:
-- Application retrieves stored username and embeds it directly
-- username = admin'-- (from the database — the single quote is NO LONGER ESCAPED)
UPDATE users
SET password_hash = '$2b$12$new_hash...'
WHERE username = 'admin'--'
The '-- in the username terminates the string literal and comments out the WHERE clause. The UPDATE now affects every row in the users table — every user’s password is changed to the attacker’s new value.
Complete authentication bypass, without ever exploiting the original INSERT.
Why This Is So Hard to Detect
Why Manual Testing Misses It
A standard penetration tester submitting ' OR '1'='1 into the registration form will see no error and no unexpected response — the storage query handles it correctly. Without knowing to also trigger the password change (or whatever downstream function uses the stored value), the tester moves on.
Why DAST Scanners Miss It
DAST scanners work by sending a payload and observing the immediate response. Second-order injection produces no observable effect on the storage request. The scanner would need to:
- Send the payload and store it
- Trigger the downstream function that uses the stored value
- Observe whether the payload executed
Most DAST tools do not model multi-step injection flows like this unless specifically configured for it.
Why Simple SAST Tools Miss It
Pattern-matching SAST tools look for user input flowing directly into a SQL query in the same block of code. In second-order injection, the input arrives in request handler A, gets stored, and is later retrieved in a completely separate handler B. The pattern-matcher sees two perfectly safe-looking code paths — it never connects them.
Only a SAST tool with interprocedural taint analysis — one that models the database as a taint propagation channel — can detect second-order SQL injection from static analysis.
Second-Order SQL Injection Code Examples
Vulnerable PHP
// registration.php — Stage 1: stores input (escaping applied, no injection)
$username = mysqli_real_escape_string($conn, $_POST['username']);
$sql = "INSERT INTO users (username, email) VALUES ('$username', '$email')";
mysqli_query($conn, $sql);
// password_change.php — Stage 2: retrieves and trusts stored data
$result = mysqli_query($conn, "SELECT username FROM users WHERE id = " . intval($_SESSION['user_id']));
$row = mysqli_fetch_assoc($result);
$username = $row['username']; // ← Tainted value from the database
// VULNERABLE: username from DB used in a new query without re-escaping
$sql = "UPDATE users SET password = '" . $new_hash . "' WHERE username = '" . $username . "'";
mysqli_query($conn, $sql);
// If username = admin'-- → updates ALL users' passwords
The fix — parameterize the second query:
// password_change.php — Stage 2 FIXED
$stmt = $conn->prepare("SELECT username FROM users WHERE id = ?");
$stmt->bind_param("i", $_SESSION['user_id']);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$username = $row['username'];
// SECURE: even DB-sourced data is treated as untrusted input
$stmt = $conn->prepare("UPDATE users SET password = ? WHERE username = ?");
$stmt->bind_param("ss", $new_hash, $username);
$stmt->execute();
Vulnerable Java
// UserRegistrationServlet.java — Stage 1: safe storage
String username = request.getParameter("username");
PreparedStatement insert = conn.prepareStatement(
"INSERT INTO users (username, email) VALUES (?, ?)"
);
insert.setString(1, username);
insert.setString(2, email);
insert.executeUpdate();
// PasswordChangeServlet.java — Stage 2: retrieves from DB, uses unsafely
Statement select = conn.createStatement();
ResultSet rs = select.executeQuery(
"SELECT username FROM users WHERE id = " + userId
);
rs.next();
String storedUsername = rs.getString("username"); // ← Tainted
// VULNERABLE: string concatenation with DB data
Statement update = conn.createStatement();
update.execute(
"UPDATE users SET password = '" + newHash + "' WHERE username = '" + storedUsername + "'"
);
The 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 parameterizes storedUsername
PreparedStatement update = conn.prepareStatement(
"UPDATE users SET password = ? WHERE username = ?"
);
update.setString(1, newHash);
update.setString(2, storedUsername); // From DB — still treated as untrusted
update.executeUpdate();
Vulnerable Python
# views.py — Stage 1: safe registration
def register(request):
username = request.POST['username']
cursor.execute(
"INSERT INTO users (username, email) VALUES (%s, %s)",
(username, email)
)
# views.py — Stage 2: retrieves and uses unsafely
def change_password(request):
row = cursor.execute(
"SELECT username FROM users WHERE id = %s", (user_id,)
).fetchone()
username = row[0] # ← Tainted value from DB
# VULNERABLE: f-string with DB-sourced data
cursor.execute(
f"UPDATE users SET password = '{new_hash}' WHERE username = '{username}'"
)
The fix:
def change_password(request):
row = cursor.execute(
"SELECT username FROM users WHERE id = %s", (user_id,)
).fetchone()
username = row[0]
# SECURE: parameterized, even for DB-sourced data
cursor.execute(
"UPDATE users SET password = %s WHERE username = %s",
(new_hash, username) # ← Parameterized
)
Vulnerable C#
// RegistrationController.cs — Stage 1: safe storage
string username = Request.Form["username"];
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(); // ← Tainted
// VULNERABLE: string interpolation with DB data
using var update = new SqlCommand(
$"UPDATE Users SET Password = '{newHash}' WHERE Username = '{storedUsername}'", conn
);
update.ExecuteNonQuery();
The 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: always parameterize, regardless of data source
using var update = new SqlCommand(
"UPDATE Users SET Password = @hash WHERE Username = @username", conn
);
update.Parameters.AddWithValue("@hash", newHash);
update.Parameters.AddWithValue("@username", storedUsername); // Still parameterized
update.ExecuteNonQuery();
OWASP Classification
Second-order SQL injection maps to:
- CWE-89: Improper Neutralization of Special Elements Used in an SQL Command
- OWASP A03:2021 — Injection
- OWASP Testing Guide WSTG-INPV-05: Testing for SQL Injection
OWASP explicitly calls out second-order injection as a separate test case because the standard first-order testing methodology (submitting a payload and checking the immediate response) does not surface it.
How to Find Second-Order SQL Injection in Your Codebase
Manual Code Review
Search for any place where data is read from the database and then used to build a new SQL query:
# Search for patterns where DB results flow into string operations before SQL calls
grep -rn "execute\|query\|ExecuteNonQuery" src/ | grep -v "PreparedStatement\|prepare\|@param"
Red flags to look for in code review:
- String concatenation in any SQL statement —
"SELECT ... '" + variable + "'"is always wrong, regardless of wherevariablecame from - Comments like “safe — this came from our DB” — this assumption is the vulnerability
- Multi-step operations — registration followed by profile update, login followed by audit logging, import followed by processing
- Stored procedure dynamic SQL —
SET @sql = 'SELECT ... ' + @username; EXEC sp_executesql @sql;
SAST Detection
A SAST tool that performs interprocedural taint analysis models the database as a taint channel:
- User input enters via HTTP parameter, form field, or API body → tainted
- Stored in DB via INSERT or UPDATE → value is written with taint metadata
- Retrieved from DB via SELECT → retrieved value is still tainted
- Used in a new SQL query without parameterization → flagged as second-order SQL injection
Tools with this capability include Checkmarx (queries: SQL_Injection_Second_Order), Fortify SCA, and Offensive360. Pattern-based tools (Semgrep, basic SonarQube, many linters) will not detect this vulnerability class.
Dynamic Testing
To test manually for second-order SQL injection:
- Register or create a record with a payload as the stored value:
test' OR '1'='1 - Trigger every downstream function that could use the stored value: password change, profile update, report generation, export, admin view
- Observe application behavior — errors, unexpected data returned, behavioral changes
- Check database contents if you have access — look for unexpected data modifications
Prevention: The Golden Rule
Treat data from the database with the same distrust as data from the user.
The database is not a sanitizer. Data that was safely inserted with escaping or parameterization is stored as a literal string — but when it’s retrieved and re-used, the escaping is gone. The retrieved value is raw text, and it will be interpreted by whatever context it enters next.
The only reliable prevention:
- Parameterize every SQL query, including those that use data retrieved from the database
- Use an ORM — Hibernate, Entity Framework, Django ORM, and SQLAlchemy all parameterize by default, preventing both first- and second-order injection in most common patterns
- Code review multi-step data flows — explicitly review registration → downstream use paths for SQL construction
- SAST with interprocedural taint analysis — automate detection across all code paths, including ones that cross file and class boundaries
Summary
| First-Order SQL Injection | Second-Order SQL Injection | |
|---|---|---|
| Payload execution | Immediate | Delayed (stored → retrieved → executed) |
| Detection difficulty | Medium | High |
| Standard DAST detection | Usually yes | Usually no |
| Pattern-matching SAST | Usually yes | Usually no |
| Taint-analysis SAST | Yes | Yes |
| Fix | Parameterized query at input | Parameterized query at every SQL call |
Second-order SQL injection remains one of the most frequently missed vulnerability classes in web application security assessments because it requires connecting two code paths that appear individually safe. The fix is simple — parameterize everything — but finding the vulnerability requires analysis that crosses the conventional boundary of “sanitized at input = safe.”
Offensive360’s SAST engine detects second-order SQL injection through interprocedural taint analysis that models database read/write operations as taint propagation channels. Scan your codebase for $500 or book a demo to see a live detection walkthrough.