Skip to main content

Free 30-min security demo Book Now

Offensive360 Offensive360
Vulnerability Research

2nd Order SQL Injection: Attack, Examples & Detection (2026)

2nd order SQL injection stores a malicious payload in the database and fires it in a later query — bypassing most scanners. Real attack examples, exploit code, and fixes.

Offensive360 Security Research Team — min read
2nd order SQL injection second-order SQL injection second order sql injection 2nd order sql injection stored sql injection persistent sql injection SQL injection web security OWASP CWE-89 SAST database security sql injection examples second order injection attack 2nd order sqli

2nd order SQL injection (also written as second-order SQL injection or stored SQL injection) is a two-stage attack where an attacker’s malicious payload is accepted and stored safely — then fired into a SQL query later, in a completely different request or code path. Because injection and execution are separated in time, most automated scanners miss it entirely.

This guide explains exactly how 2nd order SQL injection works, walks through realistic attack scenarios with working exploit code, shows how developers accidentally introduce it, and covers the detection and fix strategies that actually work.


How 2nd Order SQL Injection Differs from Classic SQLi

In classic SQL injection, the attacker submits a payload in a request parameter and it is immediately processed by a SQL query. The vulnerable server reads user input → builds a SQL string → executes it — all in the same request:

GET /search?q=' OR '1'='1 HTTP/1.1
-- Immediate execution, immediate feedback
SELECT * FROM products WHERE name = '' OR '1'='1'

In 2nd order SQL injection, the attack is split across two operations:

Stage 1 — Store (no injection yet): The attacker submits a malicious payload. The application escapes it properly on the way in, so the INSERT succeeds cleanly. The payload is now sitting in the database as “safe” data.

Stage 2 — Execute (injection fires): Later — in a different request, often triggered by another user’s action — the application retrieves the stored value and embeds it into a new SQL query without sanitizing it again, because the code “trusts” data that came from its own database.

This trusted-source assumption is the root cause. The database is treated as a sanitization layer when it is not one.


A Complete 2nd Order SQLi Attack Example

Setup: Vulnerable Registration + Password Change Flow

A web application has two endpoints:

  1. POST /register — accepts a username, escapes it, and inserts into the users table
  2. POST /change-password — retrieves the current username from the session, uses it to update the password

The password-change query is written this way:

// Retrieves current user from DB — "safe" internal data
$result = $db->query("SELECT username FROM users WHERE id = " . $_SESSION['user_id']);
$row = $result->fetch_assoc();
$username = $row['username']; // <-- comes from database, treated as trusted

// Builds a new query using the "trusted" username
$new_hash = password_hash($_POST['new_password'], PASSWORD_BCRYPT);
$sql = "UPDATE users SET password = '$new_hash' WHERE username = '$username'";
$db->query($sql); // 2nd order injection fires here

The Attack

Step 1: The attacker registers with this username:

admin'--

The registration code properly escapes it before INSERT:

INSERT INTO users (username, password) 
VALUES ('admin''--', '[bcrypt hash]');

The row is stored safely. No error, no sign of attack.

Step 2: The attacker logs in as admin'-- and requests a password change to NewPassword123!.

Step 3: The password-change handler retrieves the username from the database:

username = "admin'--"   # Retrieved from DB — trusted without escaping

It then builds the UPDATE query:

UPDATE users SET password = '[new_bcrypt_hash]' WHERE username = 'admin'--'

The -- comments out the rest of the WHERE clause. The query becomes:

UPDATE users SET password = '[new_bcrypt_hash]' WHERE username = 'admin'
-- ' (rest of original query commented out)

Result: The attacker has reset the actual admin account’s password to NewPassword123!. They can now log in as admin.


More 2nd Order SQLi Attack Scenarios

Scenario 2: Privilege Escalation via Profile Data

An attacker stores a malicious value in their email field:

[email protected]' UNION SELECT id, username, password, email FROM admins--

Later, when an admin generates a user report, the reporting query embeds the stored email:

SELECT * FROM activity_log WHERE user_email = '[email protected]' 
UNION SELECT id, username, password, email FROM admins--'

The attacker receives all admin account details in the report output.

Scenario 3: Order Data Used in Shipping Query

An attacker places an order with a malicious shipping address:

123 Main St'; UPDATE orders SET status='shipped' WHERE status='pending'--

When the fulfilment system processes pending orders using stored addresses:

UPDATE shipments SET address = '123 Main St'; 
UPDATE orders SET status='shipped' WHERE status='pending'--'
WHERE order_id = 1234

All pending orders are marked as shipped.

Some applications store session data — including user-controlled values — in a database and later use that data in queries:

# On login, user preferences are loaded from the DB and stored in session
session['timezone'] = db.query("SELECT timezone FROM prefs WHERE user_id = %s", user_id)

# Later, a report generator uses the session timezone value in a raw query
query = f"SELECT * FROM events WHERE timezone = '{session['timezone']}'"
db.execute(query)  # 2nd order injection if timezone was stored maliciously

Why Scanners Miss 2nd Order SQL Injection

Standard DAST (Dynamic Application Security Testing) scanners test inputs and immediately look for injection signals in the response: database error messages, timing delays, or changed output. In 2nd order SQLi, the injection stage produces no signal whatsoever — the payload is stored cleanly. The scanner moves on, having seen no vulnerability.

Standard SAST (Static Application Security Testing) tools using pattern matching look for user-controlled input reaching a SQL query. They identify the $_POST['username'] source and trace it to the INSERT statement — which uses proper escaping. The first query looks safe. The second query uses a variable retrieved from the database — which appears to be an “internal” source, not user input. The pattern-matcher stops tracing.

What is required to detect 2nd order SQLi: The SAST tool must perform interprocedural taint analysis that:

  1. Marks user input as tainted when it enters the application
  2. Tracks the tainted value into the database write operation
  3. Tracks the read from that same table as a source of tainted data
  4. Follows the retrieved value into the second SQL query and confirms it reaches the sink without parameterization

This cross-request, read-write-read taint tracking is significantly more complex than standard injection detection, which is why most tools miss it.


Vulnerable vs. Secure Code Patterns

PHP

<?php
// VULNERABLE — 2nd order SQL injection
// Step 1: Registration (looks safe — data is escaped on input)
$username = $mysqli->real_escape_string($_POST['username']);
$hash = password_hash($_POST['password'], PASSWORD_BCRYPT);
$mysqli->query("INSERT INTO users (username, hash) VALUES ('$username', '$hash')");

// Step 2: Password change (the bug — DB data used without parameterization)
$result = $mysqli->query("SELECT username FROM users WHERE id=" . (int)$_SESSION['user_id']);
$row = $result->fetch_assoc();
$trusted_username = $row['username']; // <-- "trusted" but tainted

$new_hash = password_hash($_POST['new_password'], PASSWORD_BCRYPT);
$mysqli->query("UPDATE users SET hash='$new_hash' WHERE username='$trusted_username'"); // VULNERABLE
<?php
// SECURE — parameterized queries everywhere, including when data comes from the DB
// Step 1: Registration
$stmt = $mysqli->prepare("INSERT INTO users (username, hash) VALUES (?, ?)");
$stmt->bind_param("ss", $_POST['username'], password_hash($_POST['password'], PASSWORD_BCRYPT));
$stmt->execute();

// Step 2: Password change — parameterized even with DB-sourced data
$stmt = $mysqli->prepare("SELECT username FROM users WHERE id = ?");
$stmt->bind_param("i", $_SESSION['user_id']);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();

$new_hash = password_hash($_POST['new_password'], PASSWORD_BCRYPT);
$stmt = $mysqli->prepare("UPDATE users SET hash = ? WHERE username = ?");
$stmt->bind_param("ss", $new_hash, $row['username']); // Still parameterized
$stmt->execute();

Python (SQLAlchemy)

# VULNERABLE — raw query with DB-sourced data
def change_password(user_id: int, new_password: str, db: Session):
    username = db.execute(
        "SELECT username FROM users WHERE id = :id", {"id": user_id}
    ).scalar()

    # DB data embedded directly into new raw SQL
    new_hash = bcrypt.hash(new_password)
    db.execute(f"UPDATE users SET hash = '{new_hash}' WHERE username = '{username}'")
    db.commit()


# SECURE — always parameterize, even DB-sourced values
def change_password(user_id: int, new_password: str, db: Session):
    username = db.execute(
        text("SELECT username FROM users WHERE id = :id"), {"id": user_id}
    ).scalar()

    new_hash = bcrypt.hash(new_password)
    db.execute(
        text("UPDATE users SET hash = :hash WHERE username = :username"),
        {"hash": new_hash, "username": username}   # Parameterized
    )
    db.commit()

Java (JDBC)

// VULNERABLE — 2nd order SQLi via "trusted" DB value
public void changePassword(int userId, String newPassword) throws SQLException {
    // Fetch username from DB — assumed safe
    ResultSet rs = stmt.executeQuery("SELECT username FROM users WHERE id = " + userId);
    String username = rs.getString("username");  // Tainted value

    // Used in a new query without parameterization
    String newHash = BCrypt.hashpw(newPassword, BCrypt.gensalt());
    stmt.executeUpdate(
        "UPDATE users SET hash = '" + newHash + "' WHERE username = '" + username + "'"
    ); // VULNERABLE
}


// SECURE — PreparedStatement for every query, including with DB-sourced data
public void changePassword(int userId, String newPassword) throws SQLException {
    // Fetch username safely
    PreparedStatement fetchStmt = conn.prepareStatement(
        "SELECT username FROM users WHERE id = ?"
    );
    fetchStmt.setInt(1, userId);
    ResultSet rs = fetchStmt.executeQuery();
    String username = rs.getString("username");

    // Update with parameterized query — even though username came from DB
    String newHash = BCrypt.hashpw(newPassword, BCrypt.gensalt());
    PreparedStatement updateStmt = conn.prepareStatement(
        "UPDATE users SET hash = ? WHERE username = ?"
    );
    updateStmt.setString(1, newHash);
    updateStmt.setString(2, username);  // DB value still parameterized
    updateStmt.executeUpdate();
}

Node.js (mysql2)

// VULNERABLE — 2nd order SQLi
async function changePassword(userId, newPassword) {
  const [rows] = await db.execute('SELECT username FROM users WHERE id = ?', [userId]);
  const { username } = rows[0]; // Retrieved from DB

  const hash = await bcrypt.hash(newPassword, 12);
  // String interpolation with DB value — 2nd order injection
  await db.query(`UPDATE users SET hash = '${hash}' WHERE username = '${username}'`);
}


// SECURE — parameterize even DB-sourced values
async function changePassword(userId, newPassword) {
  const [rows] = await db.execute('SELECT username FROM users WHERE id = ?', [userId]);
  const { username } = rows[0];

  const hash = await bcrypt.hash(newPassword, 12);
  await db.execute(
    'UPDATE users SET hash = ? WHERE username = ?',
    [hash, username]  // DB-sourced value still passed as parameter
  );
}

The Fix: One Rule That Eliminates 2nd Order SQLi

Never trust data just because it came from your own database.

The database is a store and retrieval layer — not a sanitization layer. A value that entered your database from user input carries the taint of that origin indefinitely. If it came from user input at any point in its lifecycle, it must be parameterized in every subsequent query.

The universal fix is parameterized queries (prepared statements) applied to every SQL statement — including and especially those that use data retrieved from the database. This single rule eliminates both classic SQL injection and 2nd order SQL injection simultaneously.

If you are using an ORM (Hibernate, Django ORM, ActiveRecord, Prisma, SQLAlchemy with ORM mode), parameterization is automatic for ORM-generated queries. The risk reappears only when raw SQL strings are used via escape hatches like fromSqlRaw(), execute(), or raw().


Defense-in-Depth for 2nd Order SQLi

Parameterization is the primary fix. These additional controls reduce the blast radius if parameterization is missed anywhere:

Input Validation at Registration

Reject usernames that contain SQL metacharacters at the point of registration. This is not a substitute for parameterization (attackers can work around character-level filters), but it eliminates the most obvious payloads:

import re

def validate_username(username: str) -> bool:
    # Allowlist: alphanumeric + underscore + hyphen only
    return bool(re.match(r'^[a-zA-Z0-9_\-]{3,32}$', username))

Least-Privilege Database Accounts

Grant the application database user the minimum permissions required. If the app only needs to SELECT, INSERT, and UPDATE specific tables, it should not have ALTER, DROP, or GRANT privileges:

-- Minimum necessary permissions for a web application
GRANT SELECT, INSERT, UPDATE ON app_db.users TO 'appuser'@'localhost';
GRANT SELECT, INSERT ON app_db.activity_log TO 'appuser'@'localhost';
-- No DROP, ALTER, CREATE, GRANT

This limits what an attacker can do even if injection succeeds.

Web Application Firewall (WAF) as Defense-in-Depth

A WAF can catch some obvious SQL injection payloads at the network level. However, WAFs are not reliable defenses against 2nd order SQLi — the injection payload typically looks like normal registration data at submission time and only becomes dangerous at execution time, inside your application. Do not rely on a WAF in place of parameterized queries.


OWASP Classification

2nd order SQL injection falls under:

  • OWASP Top 10 A03:2021 — Injection
  • CWE-89: Improper Neutralization of Special Elements Used in an SQL Command
  • OWASP Testing Guide: OTG-INPVAL-005 (Testing for SQL Injection — specifically addresses stored/2nd order variants)

The OWASP Testing Guide notes that 2nd order injection requires testers to “correlate storage and retrieval points” — a manual analysis step that automated tools frequently miss.


How SAST Can Detect 2nd Order SQL Injection

SAST detection of 2nd order SQLi requires cross-function, cross-request taint tracking:

  1. Identify user-controlled taint sources: request.POST, req.body, $_POST, @RequestParam, etc.
  2. Track writes to the database: Mark any data written to a DB table as “potentially tainted” if its value traces to a user-controlled source
  3. Track reads from the database: Any subsequent query that reads from a table that received tainted writes produces a tainted result set
  4. Follow into SQL sinks: If the tainted read value reaches a SQL string concatenation or format operation without parameterization, it is a 2nd order SQLi finding

This analysis crosses function and file boundaries and must model the database as a taint propagation channel — capabilities that only deep interprocedural SAST engines provide.

Offensive360’s SAST engine performs this class of analysis across all supported languages, tracking taint through database read/write operations to detect second-order injection that pattern-matching tools and most conventional SAST scanners miss. See our second-order SQL injection detection guide for a deeper look at how the vulnerability class works.


Testing for 2nd Order SQL Injection Manually

Because automated tools often miss this class, manual testing is essential:

  1. Map storage → retrieval pairs: Identify every place user input is stored (registration, profile update, order creation, comment submission). Then identify every place that stored data is later retrieved and used in application logic.

  2. Inject SQL metacharacters at storage: Submit payloads like admin'--, test'; DROP TABLE users;--, '; UPDATE users SET role='admin'-- in every stored field.

  3. Trigger the retrieval: Perform the actions that retrieve and use the stored data — password change, profile view by admin, order status update, report generation.

  4. Look for execution signals: Changed behavior, errors, unexpected query results, or the impact of the payload (e.g., a different user’s password changed).

  5. Check all users’ stored data: Some retrieval paths are triggered by admin actions — test as the injecting user, then observe effects on other accounts.


Summary

Classic SQL Injection2nd Order SQL Injection
Execution timingImmediate (same request)Delayed (later request/action)
Injection visibilityDirectly observableHidden — no signal at injection time
Scanner detection rateUsually detectedOften missed by automated tools
Root causeUnsanitized user input in SQLTrusted DB data used without parameterization
Requires manual testingRarelyUsually yes
FixParameterized queriesParameterized queries everywhere — including DB-sourced data

The key takeaway: the database is not a trust boundary. Data retrieved from your database must be treated as untrusted in any new SQL query, regardless of how safely it was stored. Parameterized queries for every SQL statement — not just those that take direct user input — is the only reliable defense.


Offensive360 SAST detects 2nd order SQL injection through deep interprocedural taint analysis across Java, C#, Python, PHP, Node.js, and 60+ other languages. Run a one-time code scan for $500 to find stored injection vulnerabilities in your codebase — results within 48 hours.

Offensive360 Security Research Team

Application Security Research

Find vulnerabilities before attackers do

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