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.
Anay Pandya
1,626 words
Anay Pandya
Founder of ZeriFlow · 10 years fullstack engineering · About the author
Key Takeaways
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.
Is your site actually secure?
Run a free check — 60 seconds
Keep reading
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.
// 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.
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.
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.
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.
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]
);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]
);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)
)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.
ORMs like Prisma, Sequelize, and SQLAlchemy use parameterized queries internally for standard operations. For most CRUD operations, they are safe out of the box.
// Safe — Prisma generates a parameterized query
const user = await prisma.user.findFirst({
where: {
email: userInput,
active: true,
},
});$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);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 },
});# 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 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 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]
);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.
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:
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.
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" .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.
Related resources
Website Vulnerability Scanner
Run a broader website security audit across headers, TLS, DNS, cookies, SEO, and disclosure checks.
Security Headers Checker
Check CSP, HSTS, X-Frame-Options, and other response headers.
SSL Checker
Review TLS certificate, HTTPS, and transport security signals.
DMARC Checker
Validate email authentication records for domain spoofing protection.
CSP Checker
Review Content-Security-Policy coverage and common gaps.
April 30, 2026
Best SAST Tools for Small Development Teams in 2026 (Free Included)
Monitor your security score
Schedule recurring scans and catch regressions after deployments.