Second-Order SQL Injection
debt(d8/e5/b5/t8)
Closest to 'silent in production until users hit it' (d9), nudged to d8 because semgrep can catch DB-read-to-query concatenation patterns, but most SAST tools miss the data-flow across storage boundaries.
Closest to 'touches multiple files / significant refactor' (e5) — quick_fix says every DB operation including reads of stored data must use prepared statements, requiring audit and rewrite of many query sites across the codebase.
Closest to 'persistent productivity tax' (b5) — applies_to web and cli contexts; every developer must remember that DB-sourced values are not trusted, shaping how all subsequent queries are written.
Closest to 'serious trap that contradicts how a similar concept works elsewhere' (t7), nudged to t8 because the misconception is exactly the intuitive belief — data safely stored via prepared statements feels trustworthy on read, but isn't.
Also Known As
TL;DR
Explanation
Second-order (or stored) SQL injection occurs when user input is properly escaped on first insertion but later retrieved from the database and interpolated into another query without re-escaping. Developers often trust data already in their database, forgetting it may be attacker-controlled. The fix is identical to first-order injection prevention: use parameterised queries and prepared statements everywhere data is used in a query, regardless of its origin.
How It's Exploited
Common Misconception
Why It Matters
Common Mistakes
- Trusting data retrieved from the database as 'safe' because it was stored via parameterised query.
- Not parameterising queries that use database-sourced values — escaped on insert, concatenated on use.
- Username/email stored safely but used as-is in dynamic SQL in stored procedures or admin queries.
- Not auditing code that reads from the DB and then uses the value in another query.
Code Examples
// Safe insert — parameterised:
$stmt = $pdo->prepare('INSERT INTO users (name) VALUES (?)');
$stmt->execute(["' OR '1'='1"]); // Stored safely
// Unsafe later use — second-order injection:
$name = $pdo->query('SELECT name FROM users WHERE id = 1')->fetchColumn();
$users = $pdo->query("SELECT * FROM logs WHERE user = '$name'"); // SQLI!
// Always use prepared statements at query time — even for 'stored safe' data:
public function promoteToAdmin(int $userId): void {
// Even if $userId came from the DB and looks safe:
$stmt = $this->pdo->prepare(
'UPDATE users SET role = ? WHERE id = ?'
);
$stmt->execute(['admin', $userId]);
}
// Fetch then re-use safely:
$username = $this->pdo
->prepare('SELECT username FROM users WHERE id = ?')
->execute([$id])
->fetchColumn();
// Now use it safely in another query:
$stmt = $this->pdo->prepare('SELECT * FROM logs WHERE username = ?');
$stmt->execute([$username]); // Prepared — safe regardless of stored content