SQL Injection: The Attack That Refuses to Die
SQL injection was first documented in 1998 — over 27 years ago. Yet in 2026, it remains in the OWASP Top 10 and continues to be one of the most common and devastating web application vulnerabilities. The Verizon 2025 Data Breach Investigations Report found that SQL injection was involved in 23% of all web application breaches.
Why does SQL injection persist? Because it is absurdly easy to introduce — a single line of insecure code can expose an entire database — and many developers still learn database interaction through tutorials that demonstrate vulnerable patterns without adequate warnings.
This guide covers SQL injection from both the developer and DBA perspective: how attacks work, how to prevent them in every major programming language, and how to harden your database as a last line of defense.
The Cost of SQL Injection: The average cost of a data breach caused by SQL injection is $4.88 million according to IBM's 2025 Cost of a Data Breach Report. Beyond financial cost, breaches destroy customer trust and can result in regulatory penalties under GDPR, CCPA, and other data protection laws.
How SQL Injection Works
The Vulnerable Pattern
SQL injection occurs when user input is directly concatenated into SQL queries without proper handling. Consider this PHP code:
// VULNERABLE — Never do this!
$username = $_POST['username'];
$query = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
$result = pg_query($conn, $query);
If a user enters admin' OR '1'='1' -- as the username, the query becomes:
SELECT * FROM users WHERE username = 'admin' OR '1'='1' --' AND password = ''
The -- comments out the rest of the query. The condition '1'='1' is always true, so the query returns all users. The attacker is now logged in as the first user in the database — usually the admin.
Types of SQL Injection
| Type | How It Works | Detectability | Danger Level |
|---|---|---|---|
| Classic (In-band) | Results displayed directly on page | Easy to detect | Critical |
| Error-based | Database error messages reveal data | Easy to detect | High |
| UNION-based | UNION SELECT to extract other tables | Moderate | Critical |
| Blind (Boolean) | True/false responses reveal data bit by bit | Hard to detect | Critical |
| Blind (Time-based) | SLEEP/WAITFOR delays reveal data | Hard to detect | Critical |
| Out-of-band | Data exfiltrated via DNS/HTTP requests | Very hard to detect | Critical |
| Second-order | Stored payload executes later | Very hard to detect | Critical |
Source: OWASP — SQL Injection
Prevention: Parameterized Queries in Every Language
The single most effective defense against SQL injection is parameterized queries (also called prepared statements). They separate SQL code from data, making injection impossible.
PHP (PDO)
// SECURE — Parameterized query with PDO
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username AND password = :password");
$stmt->execute([
':username' => $username,
':password' => $hashedPassword
]);
$user = $stmt->fetch();
// SECURE — With pg_query_params (PostgreSQL native)
$result = pg_query_params($conn,
'SELECT * FROM users WHERE username = $1 AND password = $2',
[$username, $hashedPassword]
);
Python
# SECURE — psycopg2 (PostgreSQL)
cursor.execute(
"SELECT * FROM users WHERE username = %s AND password = %s",
(username, hashed_password)
)
# SECURE — SQLAlchemy ORM
user = session.query(User).filter(
User.username == username,
User.password == hashed_password
).first()
# SECURE — Django ORM
user = User.objects.filter(username=username, password=hashed_password).first()
Node.js
// SECURE — node-postgres (pg)
const result = await pool.query(
'SELECT * FROM users WHERE username = $1 AND password = $2',
[username, hashedPassword]
);
// SECURE — Knex.js query builder
const user = await knex('users')
.where({ username, password: hashedPassword })
.first();
// SECURE — Prisma ORM
const user = await prisma.user.findFirst({
where: { username, password: hashedPassword }
});
Java
// SECURE — PreparedStatement
PreparedStatement stmt = conn.prepareStatement(
"SELECT * FROM users WHERE username = ? AND password = ?"
);
stmt.setString(1, username);
stmt.setString(2, hashedPassword);
ResultSet rs = stmt.executeQuery();
Go
// SECURE — database/sql parameterized query
row := db.QueryRow(
"SELECT id, username FROM users WHERE username = $1 AND password = $2",
username, hashedPassword,
)
err := row.Scan(&id, &name)
Source: OWASP — Query Parameterization Cheat Sheet
ORM Safety: Not Always Automatic
ORMs (Object-Relational Mappers) generally protect against SQL injection by using parameterized queries internally. However, raw query methods in ORMs can still be vulnerable:
# Django — SAFE (ORM query)
User.objects.filter(username=username)
# Django — VULNERABLE (raw SQL with string formatting)
User.objects.raw(f"SELECT * FROM users WHERE username = '{username}'" )
# Django — SAFE (raw SQL with parameters)
User.objects.raw("SELECT * FROM users WHERE username = %s", [username])
# SQLAlchemy — VULNERABLE
engine.execute(f"SELECT * FROM users WHERE username = '{username}'")
# SQLAlchemy — SAFE
engine.execute(text("SELECT * FROM users WHERE username = :name"), {"name": username})
Rule: If you must use raw SQL in an ORM, always use the parameterized version. Never use string formatting (f-strings, format(), concatenation) with user input in SQL.
Database-Level Defenses (Defense in Depth)
Application-level prevention is the primary defense, but a hardened database provides critical additional protection.
Principle of Least Privilege
-- Create application-specific database user with minimal permissions
CREATE USER app_user WITH PASSWORD 'strong_random_password';
-- Grant only necessary permissions
GRANT CONNECT ON DATABASE myapp TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO app_user;
-- NEVER grant these to application users:
-- GRANT DROP, CREATE, ALTER, SUPERUSER, REPLICATION
-- For read-only reporting connections
CREATE USER report_user WITH PASSWORD 'another_strong_password';
GRANT CONNECT ON DATABASE myapp TO report_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO report_user;
Additional Database Hardening
| Defense | Implementation | Protection Level |
|---|---|---|
| Least privilege | App user has only SELECT/INSERT/UPDATE | Critical |
| No DROP/ALTER permission | Prevents table destruction via injection | Critical |
| Separate read/write users | Read-only user for reporting queries | High |
| Disable multi-statement | Most drivers disable by default | High |
| Row-level security | PostgreSQL RLS policies | High |
| Audit logging | Log all queries from app user | Medium (detection) |
| Network restrictions | pg_hba.conf limits connections by IP | High |
| Encrypted connections | SSL/TLS required in pg_hba.conf | High |
Source: PostgreSQL — GRANT Documentation | PostgreSQL — Row Security Policies
Web Application Firewall (WAF) Configuration
A WAF provides an additional detection layer. ModSecurity with the OWASP Core Rule Set (CRS) is the industry standard open-source WAF:
# ModSecurity basic SQL injection rules
# /etc/modsecurity/modsecurity.conf
SecRuleEngine On
SecRequestBodyAccess On
# Block common SQL injection patterns
SecRule ARGS "@detectSQLi" \
"id:942100,\
phase:2,\
block,\
msg:'SQL Injection Attack Detected',\
logdata:'Matched Data: %{TX.0}',\
severity:CRITICAL"
Source: OWASP — ModSecurity Core Rule Set
Real-World SQL Injection Case Studies
Heartland Payment Systems (2008)
SQL injection led to the theft of 130 million credit card numbers. The attacker used SQL injection to install malware that captured card data in transit. Total cost: over $140 million in penalties and remediation.
Sony Pictures (2011)
A basic SQL injection attack on Sony's websites exposed 1 million user accounts. The vulnerability was a simple unsanitized input in a URL parameter — exactly the type of bug a parameterized query would have prevented.
TalkTalk (2015)
A 15-year-old teenager used SQL injection to access the personal data of 157,000 customers. TalkTalk was fined £400,000 by the UK ICO. The CEO resigned.
SQL Injection Prevention Checklist
| Layer | Control | Priority |
|---|---|---|
| Application | Use parameterized queries / prepared statements everywhere | Critical |
| Application | Use ORM default query methods (avoid raw SQL) | Critical |
| Application | Validate and sanitize all user input | High |
| Application | Use allowlists for dynamic column/table names | High |
| Database | Apply principle of least privilege | Critical |
| Database | Remove DROP/ALTER permissions from app user | Critical |
| Database | Enable query audit logging | High |
| Network | Deploy WAF with SQL injection rules | High |
| Network | Restrict database access to application servers only | Critical |
| Process | Code review for SQL query construction | High |
| Process | Automated SAST/DAST scanning in CI/CD | High |
| Process | Regular penetration testing | Medium |