Antoine Duno
Founder of ZeriFlow · 10 years fullstack engineering · About the author
Key Takeaways
- SQL injection remains the most damaging injection class in web applications. This guide covers how SQLi works, parameterized queries in Node.js and Python, ORM pitfalls, and how to verify your defenses with automated testing.
- Includes copy-paste code examples and step-by-step instructions.
- Free automated scan available to verify your implementation.
SQL Injection Prevention: A Complete Guide for Web Developers (2026)
SQL injection has topped vulnerability charts for over two decades. Despite widespread awareness, it continues to appear in production systems — most often not from ignorance but from a lapse in code review, a rushed feature, or a misunderstanding of where ORM safety ends. The Verizon Data Breach Investigations Report consistently lists injection attacks among the leading causes of confirmed data breaches.
This guide gives you the full picture: how injection works at the query level, the correct prevention patterns in Node.js and Python, where ORMs protect you and where they do not, and how to test your own application.
How SQL Injection Actually Works
When user input is concatenated directly into a SQL query string, the database cannot distinguish between query structure and user-supplied data. The attacker supplies data that changes the structure of the query itself.
The Classic Example
// Vulnerable Node.js — raw string concatenation
const username = req.body.username;
const password = req.body.password;
const query = `SELECT * FROM users WHERE username = ''${username}'' AND password = ''${password}''`;If an attacker submits username = admin'' -- and any password, the query becomes:
SELECT * FROM users WHERE username = ''admin'' --'' AND password = ''anything''The -- starts a SQL comment, commenting out the password check entirely. The attacker logs in as admin with no valid credentials.
Data Extraction via UNION Injection
username = '' UNION SELECT username, password_hash, null, null FROM users --The query now returns rows from a completely different table. If your application renders query results anywhere in the response, the attacker reads your entire user table.
Blind SQL Injection
When the application does not display query results, attackers use boolean-based or time-based inference:
'' AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username=''admin'')=''a'' --
'' AND SLEEP(5) --If the page behaves differently (or the response is delayed five seconds), the condition is true. Automated tools enumerate the entire database character by character this way.
The Fix: Parameterized Queries
Parameterized queries (also called prepared statements) send the SQL structure and the data separately to the database engine. The database parses the query first, then slots in the data as literal values — no re-parsing. The user-supplied data can never change query structure.
Node.js with pg (PostgreSQL)
// Vulnerable
const result = await client.query(
`SELECT * FROM users WHERE username = ''${username}'' AND password = ''${password}''`
);
// Secure — parameterized
const result = await client.query(
''SELECT * FROM users WHERE username = $1 AND password = $2'',
[username, password]
);Node.js with mysql2
// Vulnerable
connection.query(`SELECT * FROM orders WHERE user_id = ${userId}`, callback);
// Secure
connection.query(''SELECT * FROM orders WHERE user_id = ?'', [userId], callback);
// With async/await
const [rows] = await connection.promise().execute(
''SELECT * FROM orders WHERE user_id = ?'',
[userId]
);Python with psycopg2
# Vulnerable
cursor.execute(f"SELECT * FROM products WHERE category = ''{category}''")
# Secure — %s placeholder with tuple
cursor.execute(
"SELECT * FROM products WHERE category = %s AND active = %s",
(category, True)
)Python with sqlite3
# Secure — ? placeholder
cursor.execute(
"SELECT * FROM users WHERE email = ? AND role = ?",
(email, role)
)The key rule: never use f-strings, .format(), or % string formatting to build SQL queries. Always pass user data as parameters through the driver''s placeholder mechanism.
ORM Safety: Where It Helps and Where It Does Not
ORMs like Prisma, Sequelize, and SQLAlchemy use parameterized queries internally for standard operations. For most CRUD operations, they are safe out of the box.
Prisma (Node.js/TypeScript) — Safe by Default
// Safe — Prisma generates a parameterized query
const user = await prisma.user.findFirst({
where: {
email: userInput,
active: true,
},
});The Prisma Escape Hatch: $queryRaw
// Vulnerable — string interpolation in $queryRaw
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE username = ''${username}''
`;
// Secure — use Prisma.sql template tag for user data
import { Prisma } from ''@prisma/client'';
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE username = ${username}
`;
// When using the template literal tag directly (not a string), Prisma parameterizes automatically
// But if you build the query dynamically, use Prisma.sql explicitly:
const query = Prisma.sql`SELECT * FROM users WHERE username = ${username} AND role = ${role}`;
const users = await prisma.$queryRaw(query);Sequelize — Watch for where with literal()
// Safe
const user = await User.findOne({ where: { username, password } });
// Vulnerable — literal() bypasses parameterization
const users = await User.findAll({
where: sequelize.literal(`username = ''${userInput}''`),
});
// Secure — use parameterized literal
const users = await User.findAll({
where: sequelize.literal(''username = :username''),
replacements: { username: userInput },
});SQLAlchemy (Python) — ORM Layer vs. Core
# Safe — ORM query
users = session.query(User).filter(User.username == username).all()
# Also safe — Core with bound parameters
from sqlalchemy import text
result = connection.execute(
text("SELECT * FROM users WHERE username = :username"),
{"username": username}
)
# Vulnerable — string formatting into text()
result = connection.execute(
text(f"SELECT * FROM users WHERE username = ''{username}''")
)The pattern is consistent across all ORMs: use the built-in query API for filtering and only use raw query methods with explicit parameterization.
Stored Procedures
Stored procedures can help with SQLi prevention, but they are not a silver bullet. A stored procedure that builds queries with string concatenation internally is vulnerable to second-order SQL injection.
-- Vulnerable stored procedure
CREATE PROCEDURE GetUser @username NVARCHAR(50)
AS
BEGIN
EXEC(''SELECT * FROM users WHERE username = '''''' + @username + '''''''')
END
-- Secure stored procedure — direct parameter use, no dynamic SQL
CREATE PROCEDURE GetUser @username NVARCHAR(50)
AS
BEGIN
SELECT * FROM users WHERE username = @username
ENDCall stored procedures from application code using parameterized calls, not string interpolation:
# Secure call
cursor.callproc(''GetUser'', [username])Input Validation as a Complementary Layer
Input validation is not a primary SQLi defense — it is a complementary one. Parameterized queries work even with malicious input because the data never touches the query parser. However, validation reduces attack surface and catches issues early.
Validate:
- Type: is this supposed to be an integer? Enforce it: parseInt(input, 10) and verify Number.isInteger().
- Format: if it is a UUID, validate the UUID format with a regex before it reaches your ORM.
- Length: truncate or reject inputs that exceed expected lengths.
- Allowlists for structural parameters: column names and sort directions cannot be parameterized. Use allowlists.
// Column names cannot be parameterized — use an allowlist
const ALLOWED_SORT_COLUMNS = [''created_at'', ''updated_at'', ''name'', ''price''];
const ALLOWED_SORT_DIRECTIONS = [''ASC'', ''DESC''];
const sortCol = ALLOWED_SORT_COLUMNS.includes(req.query.sort) ? req.query.sort : ''created_at'';
const sortDir = ALLOWED_SORT_DIRECTIONS.includes(req.query.order?.toUpperCase()) ? req.query.order.toUpperCase() : ''ASC'';
const result = await client.query(
`SELECT * FROM products ORDER BY ${sortCol} ${sortDir} LIMIT $1`,
[limit]
);Least Privilege: Limiting Blast Radius
Even with parameterized queries, your database account should only have the permissions it needs. An application that only reads product data does not need DROP TABLE permission.
-- Create an app-specific user
CREATE USER app_readonly WITH PASSWORD ''strong_password'';
GRANT SELECT ON products, categories, inventory TO app_readonly;
-- Write account — separate from read account
CREATE USER app_write WITH PASSWORD ''another_strong_password'';
GRANT SELECT, INSERT, UPDATE ON orders, order_items TO app_write;
-- No DELETE, no access to users tableIf SQLi does occur in a read-only query path, the attacker cannot write data, drop tables, or access administrative tables they have no permission on.
WAF as a Defense Layer
A Web Application Firewall inspects HTTP requests and blocks payloads matching known SQLi patterns (UNION SELECT, OR 1=1, comment sequences, etc.). WAFs are valuable as an additional layer but have real limitations:
- Sophisticated attacks can bypass WAF signatures through encoding, whitespace variation, or uncommon syntax
- WAFs generate false positives that can break legitimate functionality
- They add latency and operational overhead
Use a WAF (AWS WAF, Cloudflare WAF, ModSecurity) as one layer of your defense, not the only layer. Parameterized queries in your code are non-bypassable; WAF rules are not.
Testing Your Application for SQL Injection
Manual testing — submit single quotes, double quotes, --, ; DROP TABLE, and '' OR ''1''=''1 into every form field, URL parameter, header, and JSON field your application accepts. Watch for SQL error messages, unexpected data, or behavioral differences.
SQLMap — the industry standard automated SQLi detection and exploitation tool:
# Basic test
sqlmap -u "https://app.example.com/products?id=1" --batch
# Test a POST request
sqlmap -u "https://app.example.com/login" --data="username=test&password=test" --batch
# Test with authentication
sqlmap -u "https://app.example.com/api/orders" --cookie="session=abc123" --batch --level=3OWASP ZAP active scanner — includes SQLi detection and integrates into CI pipelines.
Code review — grep your codebase for danger patterns:
# Find raw string concatenation with SQL keywords
grep -rn "SELECT.*\\+\\|SELECT.*format\\|SELECT.*f''" --include="*.js" --include="*.py" .
# Find .query( calls without parameterization
grep -rn ''\\.query(`'' --include="*.js" .Summary
SQL injection is preventable with 100% reliability if you use parameterized queries consistently. The implementation is not complex — it is a small syntactic habit. The risk comes when developers use raw query methods under time pressure or misunderstand which ORM methods are protected. Establish a code review rule: any raw SQL method call requires a mandatory second set of eyes.
Layer your defenses: parameterized queries first, least-privilege database accounts second, input validation for structural parameters, and WAF as your outermost layer.
ZeriFlow''s Advanced Scan analyzes your codebase for SQL injection patterns in raw query calls alongside 80+ runtime security checks — giving you a combined view of headers, DNS, TLS, and code-level risks in a single scan. Run your first scan free at zeriflow.com.
Check if your site is vulnerable to these attacks — free.
80+ automated security checks in under 60 seconds.