Skip to main content

Free 30-min security demo  — We'll scan your real code and show live findings, no commitment Book Now

Offensive360
Vulnerability Research

Second-Order SQL Injection Examples & Fix 2026

Second-order SQL injection explained with PHP, Java, Python & C# examples: how stored payloads fire in a later query, and the parameterized fix.

Offensive360 Security Research Team — min read
second order sql injection 2nd order sql injection stored sql injection SQL injection second-order SQLi OWASP CWE-89 second order sql injection examples second order sql injection fix second order sql injection detection web security SAST parameterized queries

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:

  1. Send the payload and store it
  2. Trigger the downstream function that uses the stored value
  3. 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:

  1. String concatenation in any SQL statement"SELECT ... '" + variable + "'" is always wrong, regardless of where variable came from
  2. Comments like “safe — this came from our DB” — this assumption is the vulnerability
  3. Multi-step operations — registration followed by profile update, login followed by audit logging, import followed by processing
  4. Stored procedure dynamic SQLSET @sql = 'SELECT ... ' + @username; EXEC sp_executesql @sql;

SAST Detection

A SAST tool that performs interprocedural taint analysis models the database as a taint channel:

  1. User input enters via HTTP parameter, form field, or API body → tainted
  2. Stored in DB via INSERT or UPDATE → value is written with taint metadata
  3. Retrieved from DB via SELECT → retrieved value is still tainted
  4. 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:

  1. Register or create a record with a payload as the stored value: test' OR '1'='1
  2. Trigger every downstream function that could use the stored value: password change, profile update, report generation, export, admin view
  3. Observe application behavior — errors, unexpected data returned, behavioral changes
  4. 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:

  1. Parameterize every SQL query, including those that use data retrieved from the database
  2. 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
  3. Code review multi-step data flows — explicitly review registration → downstream use paths for SQL construction
  4. SAST with interprocedural taint analysis — automate detection across all code paths, including ones that cross file and class boundaries

Summary

First-Order SQL InjectionSecond-Order SQL Injection
Payload executionImmediateDelayed (stored → retrieved → executed)
Detection difficultyMediumHigh
Standard DAST detectionUsually yesUsually no
Pattern-matching SASTUsually yesUsually no
Taint-analysis SASTYesYes
FixParameterized query at inputParameterized 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.

Offensive360 Security Research Team

Application Security Research

Updated July 8, 2026

Find vulnerabilities before attackers do

Run Offensive360 SAST and DAST against your applications and get a full vulnerability report in minutes.