2nd order SQL injection (also called second-order SQL injection, stored SQL injection, or persistent SQL injection) is a two-stage attack where the malicious payload does not execute when it is submitted — it is stored in the database and fires later, when a different code path retrieves the stored value and embeds it unsanitized into a new SQL query.
This makes it one of the most dangerous and underdetected vulnerability classes in modern web applications. Most DAST scanners miss it entirely. Most pattern-matching SAST tools miss it too. And most developers — who correctly sanitize input on the way in — never consider that data stored in their own database can be the injection source.
This guide covers exactly how 2nd order SQL injection works, why it evades automated tools, real exploitation scenarios, language-specific vulnerable and fixed code, and how SAST tools with inter-procedural taint analysis detect it.
How 2nd Order SQL Injection Differs from Classic SQL Injection
In a classic (first-order) SQL injection, the payload is submitted and executed in the same HTTP request:
-- Classic SQLi: input goes straight into a query
SELECT * FROM users WHERE username = '' OR '1'='1'--' AND password = '...'
The scanner sends a request, observes the SQL error or behavior change in the response, and flags it.
In a 2nd order SQL injection, the attack is split across two separate operations:
Stage 1 — Storage (the injection point):
POST /register
username=admin'--&password=anything
The application escapes the apostrophe for the INSERT statement — no injection fires. The string admin'-- is stored literally in the database. No error. The scanner sees nothing unusual.
Stage 2 — Execution (the trigger point): Hours or days later, a different endpoint retrieves the stored username and uses it in a new SQL query without re-parameterizing:
-- Later, password change endpoint retrieves username from DB and trusts it:
UPDATE users SET password_hash = 'newhash' WHERE username = 'admin'--'
-- The -- comments out the WHERE clause — ALL users' passwords are updated
The scanner that tested stage 1 never saw stage 2. The developer who wrote stage 2 trusted the database as a safe data source. Both assumptions were wrong.
Why Automated Tools Miss 2nd Order SQL Injection
DAST Scanners
Dynamic scanners inject payloads and look for immediate feedback: SQL error messages, timing delays (for blind injection), or content differences. In 2nd order SQLi, there is no immediate feedback at the injection point. The payload fires in a completely different request, potentially triggered by a different user (like an admin). Standard DAST tools have no mechanism to correlate the injection request with the delayed execution.
Pattern-Matching SAST Tools
Pattern-matching scanners look for dangerous patterns like "SELECT ... " + variable. But at the point where the 2nd order injection fires, the variable came from db.query("SELECT username FROM users WHERE id = ?", [id]) — a perfectly safe parameterized read. The scanner sees a parameterized query feeding a variable, then that variable in a string concatenation. Without a model of the database as a taint propagation boundary, it has no reason to flag the concatenation as dangerous.
Single-Function Taint Analysis Tools
Tools with intra-procedural taint analysis (within a single function or code block) cannot detect 2nd order SQLi because the storage and retrieval always happen in separate functions — often separate HTTP handlers, services, or even separate applications in a microservices context.
Only SAST tools with deep inter-procedural taint analysis that model database read/write operations as taint propagation boundaries can reliably detect 2nd order SQL injection.
Real Attack Scenarios
Scenario 1: Username-Based Password Reset Takeover
This is the canonical 2nd order SQL injection attack.
Attacker registers:
Username: admin'--
Password: anything
The INSERT is safe — the apostrophe is escaped. admin'-- is now in the users table.
Victim admin resets the attacker’s password (routine admin task):
-- Application builds this query:
UPDATE users SET password_hash = 'resetvalue' WHERE username = 'admin'--'
-- The -- turns it into:
UPDATE users SET password_hash = 'resetvalue' WHERE username = 'admin'
-- Every user named 'admin' gets their password reset to the attacker's chosen value
The attacker then logs in as admin with the new password.
Scenario 2: City Field → Admin Report Destruction
Attacker registers with a malicious city in their profile:
City: '; DROP TABLE orders;--
The INSERT is parameterized. The string is stored safely.
Admin generates a city-based sales report (days later):
# Administrator dashboard — retrieves cities from user profiles
cities = db.execute("SELECT DISTINCT city FROM profiles").fetchall()
for city in cities:
# VULNERABLE: trusts DB-stored data in a new query
report = db.execute(
f"SELECT * FROM sales WHERE region = '{city[0]}'"
).fetchall()
When the loop processes '; DROP TABLE orders;--, the query executes the destructive statement. The orders table is gone. The admin just ran the report — they have no idea the attacker planted the payload months ago.
Scenario 3: Display Name → UNION-Based Data Extraction
Attacker sets their display name:
Display Name: ' UNION SELECT email, password_hash, NULL FROM users--
Admin views an activity log that includes display names:
SELECT action, actor_name, timestamp
FROM activity_log
WHERE actor_name = '' UNION SELECT email, password_hash, NULL FROM users--'
The UNION appends every user’s email and password hash to the log output. The admin sees what looks like a normal log, but the attacker’s data is embedded in the results.
Vulnerable vs. Secure Code: Language-by-Language
PHP — Classic 2nd Order Pattern
Vulnerable:
// Stage 1: Registration — escaped 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);
// Stage 2: Password change — retrieves username from DB and TRUSTS it
$result = mysqli_query($conn, "SELECT username FROM users WHERE id=" . $_SESSION['uid']);
$row = mysqli_fetch_assoc($result);
$stored_username = $row['username']; // Contains: admin'--
// VULNERABLE: "trusted" database value used in a new query without parameterization
$sql = "UPDATE users SET password_hash='$new_hash' WHERE username='$stored_username'";
mysqli_query($conn, $sql);
Secure (parameterized everywhere):
// Stage 1: Parameterized INSERT
$stmt = $conn->prepare("INSERT INTO users (username, password_hash) VALUES (?, ?)");
$stmt->bind_param("ss", $username, $hash);
$stmt->execute();
// Stage 2: Parameterized SELECT, then parameterized UPDATE
$stmt = $conn->prepare("SELECT username FROM users WHERE id = ?");
$stmt->bind_param("i", $_SESSION['uid']);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
// SECURE: DB-sourced value treated as untrusted input — parameterized
$stmt = $conn->prepare("UPDATE users SET password_hash = ? WHERE username = ?");
$stmt->bind_param("ss", $new_hash, $row['username']);
$stmt->execute();
Python — Flask + Raw SQL
Vulnerable:
@app.route('/profile', methods=['POST'])
def update_profile():
# Stage 1: Safe parameterized INSERT
db.execute(
"INSERT INTO users (display_name, email) VALUES (%s, %s)",
(request.form['display_name'], request.form['email'])
)
# Different route, different developer, different day:
@app.route('/admin/activity')
@admin_required
def activity_log():
# Stage 2: Retrieves display names, uses them in a new raw query
users = db.execute("SELECT id, display_name FROM users").fetchall()
results = []
for user in users:
name = user['display_name'] # Could contain SQL payload
# VULNERABLE: string formatting with database-sourced data
logs = db.execute(
f"SELECT * FROM logs WHERE actor = '{name}' LIMIT 20"
).fetchall()
results.append({'user': name, 'logs': logs})
return jsonify(results)
Secure:
@app.route('/admin/activity')
@admin_required
def activity_log():
users = db.execute("SELECT id, display_name FROM users").fetchall()
results = []
for user in users:
name = user['display_name']
# SECURE: always parameterize, even with DB-sourced data
logs = db.execute(
"SELECT * FROM logs WHERE actor = %s LIMIT 20",
(name,) # name treated as input parameter, not trusted literal
).fetchall()
results.append({'user': name, 'logs': logs})
return jsonify(results)
Java — Spring Boot + JDBC
Vulnerable:
// Stage 1: Registration — parameterized INSERT (appears safe)
@PostMapping("/register")
public ResponseEntity<Void> register(@RequestBody RegisterRequest req) {
jdbcTemplate.update(
"INSERT INTO users (username, email) VALUES (?, ?)",
req.getUsername(), req.getEmail()
);
return ResponseEntity.ok().build();
}
// Stage 2: Password change — retrieves username, uses it unsafely
@PostMapping("/change-password")
public ResponseEntity<Void> changePassword(
@AuthenticationPrincipal UserDetails principal,
@RequestBody PasswordChangeRequest req
) {
// Retrieve stored username — developer assumes it's safe
String username = jdbcTemplate.queryForObject(
"SELECT username FROM users WHERE id = ?",
String.class,
getUserId(principal)
);
// VULNERABLE: stored username embedded in a new query
// username = "admin'--" → comments out WHERE clause
String sql = "UPDATE users SET password_hash = '" + hash(req.getNewPassword())
+ "' WHERE username = '" + username + "'";
jdbcTemplate.update(sql);
return ResponseEntity.ok().build();
}
Secure:
@PostMapping("/change-password")
public ResponseEntity<Void> changePassword(
@AuthenticationPrincipal UserDetails principal,
@RequestBody PasswordChangeRequest req
) {
String username = jdbcTemplate.queryForObject(
"SELECT username FROM users WHERE id = ?",
String.class,
getUserId(principal)
);
// SECURE: PreparedStatement with parameter binding
// username from DB is still treated as an input parameter
jdbcTemplate.update(
"UPDATE users SET password_hash = ? WHERE username = ?",
hash(req.getNewPassword()),
username // Parameterized — injection is structurally impossible
);
return ResponseEntity.ok().build();
}
C# — ASP.NET Core + ADO.NET
Vulnerable:
// Stage 1: Profile update — parameterized (safe)
[HttpPost("profile")]
public IActionResult UpdateProfile(ProfileModel model)
{
using var cmd = new SqlCommand(
"UPDATE profiles SET city = @city WHERE user_id = @uid", _conn);
cmd.Parameters.AddWithValue("@city", model.City);
cmd.Parameters.AddWithValue("@uid", User.GetUserId());
cmd.ExecuteNonQuery();
return Ok();
}
// Stage 2: Admin report — retrieves cities and uses them unsafely
[HttpGet("admin/city-report")]
[Authorize(Roles = "Admin")]
public IActionResult CityReport()
{
using var readCmd = new SqlCommand("SELECT DISTINCT city FROM profiles", _conn);
var reader = readCmd.ExecuteReader();
var results = new List<object>();
while (reader.Read())
{
string city = reader.GetString(0); // "trusted" DB data
// VULNERABLE: string interpolation with DB-sourced data
using var reportCmd = new SqlCommand(
$"SELECT COUNT(*) FROM orders WHERE ship_city = '{city}'", _conn);
results.Add(new { city, count = reportCmd.ExecuteScalar() });
}
return Ok(results);
}
Secure:
[HttpGet("admin/city-report")]
[Authorize(Roles = "Admin")]
public IActionResult CityReport()
{
using var readCmd = new SqlCommand("SELECT DISTINCT city FROM profiles", _conn);
var reader = readCmd.ExecuteReader();
var cities = new List<string>();
while (reader.Read())
cities.Add(reader.GetString(0));
reader.Close();
var results = new List<object>();
foreach (var city in cities)
{
// SECURE: parameterized even for DB-sourced values
using var reportCmd = new SqlCommand(
"SELECT COUNT(*) FROM orders WHERE ship_city = @city", _conn);
reportCmd.Parameters.AddWithValue("@city", city);
results.Add(new { city, count = reportCmd.ExecuteScalar() });
}
return Ok(results);
}
Node.js — Express + mysql2
Vulnerable:
// Stage 1: Registration — parameterized (safe)
app.post('/register', async (req, res) => {
await db.query(
'INSERT INTO users (username, email) VALUES (?, ?)',
[req.body.username, req.body.email]
);
res.json({ ok: true });
});
// Stage 2: Account settings — retrieves username, uses it in a new raw query
app.post('/change-email', requireAuth, async (req, res) => {
const [rows] = await db.query(
'SELECT username FROM users WHERE id = ?',
[req.user.id]
);
const username = rows[0].username; // "trusted" DB data
// VULNERABLE: template literal with DB-sourced username
const [result] = await db.query(
`UPDATE users SET email = '${req.body.email}' WHERE username = '${username}'`
);
res.json({ updated: result.affectedRows });
});
Secure:
app.post('/change-email', requireAuth, async (req, res) => {
const [rows] = await db.query(
'SELECT username FROM users WHERE id = ?',
[req.user.id]
);
const username = rows[0].username;
// SECURE: always use parameterized queries, even with DB-sourced values
const [result] = await db.query(
'UPDATE users SET email = ? WHERE username = ?',
[req.body.email, username] // Both parameters bound — no injection possible
);
res.json({ updated: result.affectedRows });
});
How SAST Tools Detect 2nd Order SQL Injection
What Detection Requires
Detecting 2nd order SQLi requires a SAST engine capable of:
- Identifying taint sources — user-controlled input entering the application (HTTP parameters, form fields, headers)
- Tracking taint through storage — recognizing that when tainted data is written to a database, the stored value is also tainted
- Continuing taint through retrieval — when data is read back from the database, the returned value inherits the taint status of what was stored
- Identifying injection sinks — SQL query construction using string concatenation or formatting with a tainted variable
- Cross-function, cross-request scope — the taint path must span multiple functions, classes, and often multiple HTTP handlers
This is inter-procedural taint analysis with database propagation — significantly more complex than single-function taint tracking.
Tools That Detect It
Checkmarx includes dedicated SQL_Injection_Second_Order queries in both CxSAST and Checkmarx One. These model database write operations as taint-propagating sinks and database read operations as taint-continuing sources. Detection effectiveness depends on framework support and whether the queries are enabled in the scan configuration (they are sometimes disabled by default in older configurations).
Fortify SCA uses its DataFlow engine to detect second-order patterns across function boundaries. It explicitly models database operations as taint propagation points for languages including Java, C#, and PHP.
Offensive360 SAST performs deep inter-procedural taint analysis that models all supported database access patterns as taint propagation boundaries — JDBC, ADO.NET, psycopg2, mysql2, SQLAlchemy, and more. The taint path in the report shows the complete chain from the user input source through the database write, through the database read, to the injection sink.
Tools That Miss It
Pattern-matching tools (basic Semgrep rules, ESLint security plugins, SonarQube Community Edition) and single-function taint analyzers will generally miss 2nd order SQLi. The dangerous code pattern at the sink — db.execute(f"SELECT ... WHERE x = '{db_value}'") — does not look dangerous to a tool without full taint context, because db_value came from a parameterized read.
SAST Test Case
Use this minimal Python test case to verify whether your SAST tool detects 2nd order SQL injection:
# test_2nd_order.py — SAST detection test case
import sqlite3
def store_username(user_input: str) -> None:
"""Stage 1: Stores user-controlled data with a parameterized INSERT."""
conn = sqlite3.connect(':memory:')
# Safe parameterized insert — no first-order injection here
conn.execute("INSERT INTO users (name) VALUES (?)", (user_input,))
conn.commit()
conn.close()
def get_user_logs(user_id: int) -> list:
"""Stage 2: Retrieves stored name and uses it in a new unsafe query."""
conn = sqlite3.connect(':memory:')
# Parameterized read — returns the tainted DB value
row = conn.execute(
"SELECT name FROM users WHERE id = ?", (user_id,)
).fetchone()
name = row[0] # Tainted value from database
# VULNERABLE — tainted name used in a new query without parameterization
results = conn.execute(
f"SELECT * FROM logs WHERE actor = '{name}'"
).fetchall()
conn.close()
return results
- Pattern-matching tools: no finding (the f-string query looks like it contains a DB-sourced variable, not a raw user input)
- Single-function taint analysis: no finding (taint source and sink are in different functions)
- Inter-procedural taint with DB propagation: Critical finding at the f-string query, with taint trace:
store_username(user_input)→users.name→get_user_logs()→f"SELECT * FROM logs WHERE actor = '{name}'")
Prevention: The Only Rule That Matters
The single rule that eliminates 2nd order SQL injection:
Never trust data just because it came from your own database. Parameterize every SQL query, regardless of where the input values originated.
This means:
- Data from
db.execute("SELECT ...")→ must be parameterized in the next query - Data from
session(which often comes from DB) → must be parameterized - Data from a cache like Redis (which may store user-controlled values) → must be parameterized
- Data returned by a microservice (which may store user-controlled values) → must be parameterized
The false assumption that enables 2nd order injection is that the database is a sanitization layer. It is not. It is a storage mechanism. Data that entered the database tainted comes out of the database tainted.
Using an ORM Helps — But Doesn’t Fully Eliminate the Risk
ORMs (Hibernate, Django ORM, Entity Framework, ActiveRecord, Prisma) parameterize queries by default in their standard query builder APIs. Using ORMs significantly reduces the risk of both first-order and second-order SQL injection.
However, all major ORMs also expose raw SQL execution methods for complex queries:
- Django:
MyModel.objects.raw(sql) - Hibernate:
entityManager.createNativeQuery(sql) - Entity Framework:
context.Database.ExecuteSqlRaw(sql) - Sequelize:
sequelize.query(sql)
If these raw methods are used with DB-sourced data, 2nd order injection is still possible. The parameterized alternatives must always be used:
# Django — VULNERABLE raw query with DB-sourced value
username = user.username # From DB
User.objects.raw(f"SELECT * FROM users WHERE username = '{username}'")
# Django — SECURE raw query with parameter binding
User.objects.raw("SELECT * FROM users WHERE username = %s", [username])
Input Validation at Storage
A secondary defense: validate and sanitize input when it is stored, rejecting values that contain SQL metacharacters where they are not needed.
For example, a username field should have no legitimate use for ', ", ;, --, or /*. Rejecting these characters on input (in addition to parameterizing all queries) provides defense in depth.
This is not a substitute for parameterization — it is an additional layer. An attacker who finds a second code path that doesn’t validate on storage will still be blocked by parameterized queries, but will be blocked earlier by input validation.
OWASP and CWE Classification
2nd order SQL injection falls under:
- OWASP Top 10 2021: A03 — Injection
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command (‘SQL Injection’)
- OWASP Testing Guide: WSTG-INPV-05 — the OWASP WSTG specifically covers second-order injection as a distinct test case
OWASP notes that second-order injection “occurs when the application stores data for future use in a harmful way” — distinguishing it from first-order injection, where execution is immediate.
Frequently Asked Questions
Can manual penetration testing find 2nd order SQL injection?
Yes — an experienced penetration tester who maps multi-step application flows, registers test accounts with SQL payloads in every user-controlled field, and then triggers all admin and reporting flows can surface 2nd order SQLi. The challenge is coverage: in a large application, there may be hundreds of stored fields and dozens of code paths that retrieve and re-use them. Systematic manual testing is time-consuming and easy to miss. SAST with inter-procedural taint analysis provides complete code coverage.
Is 2nd order SQL injection the same as blind SQL injection?
No. Blind SQL injection is first-order injection where the server doesn’t return error messages — you infer the result through timing delays or boolean responses. Second-order (2nd order) SQL injection is about the timing of execution, not the visibility of results. A 2nd order injection can be either visible (returns data to the admin page) or blind.
What’s the difference between 2nd order SQL injection and stored SQL injection?
They are the same thing. “Stored SQL injection,” “persistent SQL injection,” and “2nd order SQL injection” all refer to the same attack class. The term “2nd order” emphasizes the two-stage nature; “stored” emphasizes where the payload lives between the two stages.
Can a WAF prevent 2nd order SQL injection?
Web Application Firewalls (WAFs) operate on the HTTP request/response layer. A WAF inspecting the registration request might block payloads like admin'-- if they match SQL injection rules. However, WAFs operating in permissive mode (allow with logging), or configured not to filter registration fields too aggressively, will pass the payload. And a WAF has no visibility into the second stage — when the password change endpoint fires the stored payload, it’s performing a normal update operation with what looks like legitimate internal data. WAFs are not a reliable defense against 2nd order SQL injection. Parameterized queries are the only reliable fix.
Summary
| Property | 1st Order SQL Injection | 2nd Order SQL Injection |
|---|---|---|
| When does it execute? | Immediately on the injected request | Later, triggered by a separate request |
| What fires it? | User input flows directly into a query | Stored data retrieved and used in a new query |
| DAST scanner detection | Usually detectable | Usually missed |
| Pattern-matching SAST | Usually detectable | Usually missed |
| Inter-procedural SAST | Detectable | Detectable |
| Fix | Parameterize the input query | Parameterize every query, including those using DB-sourced data |
The core insight: your database is not a sanitizer. Data stored in the database retains whatever trust level it had when it was written. If a user submitted admin'-- and your application stored it, that value is attacker-controlled forever — in every query that reads and re-uses it.
Parameterized queries at every SQL execution point — not just the input-facing ones — is the only fix that structurally prevents both first-order and 2nd order SQL injection.
Offensive360 SAST detects 2nd order SQL injection through deep inter-procedural taint analysis that models database read/write operations as taint propagation boundaries. Run a one-time code scan for $500 to identify second-order and first-order injection vulnerabilities across your entire codebase — results within 48 hours.