SQL injection falls under A03:2021 (Injection) in the OWASP Top 10 2021 and has been well-understood for over two decades. It still appears in production code daily. This guide covers modern prevention techniques, ORM pitfalls developers miss, and how to detect blind SQLi in your own apps.
Why SQL Injection Still Happens
The root cause is always the same: user-controlled data is concatenated directly into a SQL query rather than separated from it. Frameworks and ORMs reduce the surface area, but they don’t eliminate it — especially when developers reach for raw queries for performance or complexity.
# Vulnerable -- never do this
user_id = request.args.get('id')
query = f"SELECT * FROM users WHERE id = {user_id}"
cursor.execute(query)
An attacker supplying 1 OR 1=1 retrieves the entire table. Supplying 1; DROP TABLE users; -- causes catastrophic data loss on some databases.
Parameterised Queries: The Non-Negotiable Fix
Parameterised queries (also called prepared statements) separate SQL structure from data. The database driver handles quoting and escaping, making injection structurally impossible for those parameters.
Python (psycopg2 / sqlite3)
# Safe -- parameter placeholder, not string formatting
cursor.execute(
"SELECT * FROM users WHERE id = %s AND active = %s",
(user_id, True)
)
# With named parameters (sqlite3)
cursor.execute(
"SELECT * FROM orders WHERE user_id = :uid AND status = :status",
{"uid": user_id, "status": "pending"}
)
Java (JDBC)
// Vulnerable
String query = "SELECT * FROM accounts WHERE username = '" + username + "'";
// Safe
PreparedStatement stmt = conn.prepareStatement(
"SELECT * FROM accounts WHERE username = ? AND password_hash = ?"
);
stmt.setString(1, username);
stmt.setString(2, passwordHash);
ResultSet rs = stmt.executeQuery();
Node.js (pg / mysql2)
// pg (PostgreSQL)
const { rows } = await pool.query(
'SELECT * FROM products WHERE category = $1 AND price < $2',
[category, maxPrice]
);
// mysql2 with prepared statements
const [rows] = await connection.execute(
'SELECT * FROM sessions WHERE token = ? AND expires_at > NOW()',
[token]
);
ORM Pitfalls
ORMs are safe by default for basic queries, but developers regularly bypass their protections without realising it.
Raw Query Escapes (Django)
# Dangerous -- raw() with string formatting
User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{username}'")
# Safe -- raw() with parameterisation
User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [username])
# Safe -- use the ORM
User.objects.filter(username=username)
Sequelize (Node.js)
// Dangerous -- literal injection into where clause
const users = await User.findAll({
where: sequelize.literal(`username = '${username}'`)
});
// Safe -- use model methods
const users = await User.findAll({
where: { username: username }
});
// Safe -- replacements for raw queries
const users = await sequelize.query(
'SELECT * FROM users WHERE username = :name',
{ replacements: { name: username }, type: QueryTypes.SELECT }
);
Hibernate (Java)
// Dangerous -- HQL string concatenation
Query q = session.createQuery(
"FROM User WHERE username = '" + username + "'"
);
// Safe -- named parameters
Query q = session.createQuery("FROM User WHERE username = :name");
q.setParameter("name", username);
// Safe -- Criteria API
CriteriaBuilder cb = session.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);
Root<User> root = cq.from(User.class);
cq.where(cb.equal(root.get("username"), username));
Blind SQL Injection Patterns
Not all SQLi produces visible output. Blind SQLi extracts data through side channels:
Boolean-based: The response differs based on a true/false condition.
/api/user?id=1 AND 1=1 -- normal response
/api/user?id=1 AND 1=2 -- empty or error response
Time-based: Delays confirm injection even when output is identical.
-- PostgreSQL
1; SELECT pg_sleep(5)--
-- MySQL
1 AND SLEEP(5)--
-- MSSQL
1; WAITFOR DELAY '0:0:5'--
Detecting it in your own app: Use tools like sqlmap in safe mode (--level=1 --risk=1) against your staging environment, or manually test input fields with ', '', 1'1, and time-delay payloads, watching for response time anomalies.
Defence in Depth
Parameterised queries prevent SQLi, but layered controls limit blast radius:
- Least privilege: App database users should only have
SELECT/INSERT/UPDATE/DELETEon the tables they need. Never connect as a DBA account. - WAF rules: A web application firewall can catch common patterns, but is not a substitute for fixing the underlying code.
- Error handling: Never expose raw database errors to users. Log them server-side; return generic messages to clients.
- Regular scanning: Integrate
sqlmapor SAST tools (Semgrep, CodeQL) into your CI pipeline to catch regressions.
What to Check in Your Codebase
Parameterised queries and prepared statements are the only fix — there are no exceptions. If you’re using an ORM, grep for .raw(), .execute(), and literal() calls and verify every one is parameterised rather than string-formatted.
Database least-privilege is the other control that genuinely limits damage: if your app user only has SELECT/INSERT/UPDATE/DELETE on the tables it needs, a successful injection can’t drop tables or exfiltrate your entire schema. Get sqlmap or a SAST tool running in CI so you catch regressions before they reach production.