SQL injection (SQLi) has stayed on the OWASP Top 10 for 25 years and counting. The moment you concatenate user input into a SQL string instead of parameterising it, an attacker can rewrite your query logic: dump tables, create users, even execute OS commands. This post explains how SQLi works and how to eliminate it permanently.
Classic SQL Injection
// Vulnerable code
const email = req.body.email; // User input
const sql = `SELECT * FROM users WHERE email = '${email}' AND password = '${req.body.password}'`;
db.query(sql);
// Attacker submits this email:
// admin@x.com' --
// The query becomes:
// SELECT * FROM users WHERE email = 'admin@x.com' --' AND password = '...'
// Everything after -- is a comment. Password check bypassed!
Blind SQL Injection
Even when the app hides query output, an attacker can extract data from the true/false behaviour of the query. For instance, by distinguishing a login form's "wrong password" response from "user not found", the attacker guesses the password hash one character at a time.
-- Boolean-based blind SQLi
admin@x.com' AND SUBSTRING(password,1,1)='a' --
-- Response: "Wrong password" means the first character is not 'a'
-- Try 'b', 'c', ... and recover one character every ~30 requests
-- Time-based blind SQLi
admin@x.com' AND IF(SUBSTRING(password,1,1)='a', SLEEP(5), 0) --
-- If the response takes 5 seconds, the first character is 'a'
Union-Based
-- Original query: SELECT name, email FROM users WHERE id = ?
-- Attacker sends as id:
1 UNION SELECT username, password FROM admins --
-- Query becomes: SELECT name, email FROM users WHERE id = 1
-- UNION SELECT username, password FROM admins --
-- Output leaks the admins table
The One Fix: Parameterized Queries
Escape functions, blacklists and character replacement can all be bypassed. The only sound approach is prepared statements / parameterized queries, which separate the query structure from the data. Data is never interpreted as SQL.
// Node.js + pg
await pool.query(
'SELECT * FROM users WHERE email = $1 AND password_hash = $2',
[email, passwordHash]
);
// Node.js + mysql2
await db.execute(
'SELECT * FROM users WHERE email = ? AND password_hash = ?',
[email, passwordHash]
);
# Python + psycopg
cur.execute(
'SELECT * FROM users WHERE email = %s AND password_hash = %s',
(email, password_hash)
)
# Python + SQLAlchemy (ORM)
User.query.filter_by(email=email).first()
// PHP + PDO
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
If You Use an ORM...
Sequelize, Prisma, TypeORM, SQLAlchemy and Django ORM parameterize automatically. But watch out for raw-SQL escape hatches like raw(), literal() and query() — forget the parameters and you are vulnerable.
// Prisma — safe
await prisma.user.findMany({ where: { email } });
// Prisma raw — WRONG
await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE email = '${email}'`);
// Prisma raw — RIGHT
await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;
// The template literal tag parameterizes automatically
Layered Defence
- Parameterized queries — the primary defence
- Least privilege — the app DB user touches only required tables and cannot DROP/ALTER
- Input validation — type and length checks (e.g. ids must be numeric)
- WAF — Cloudflare or ModSecurity catches known attack patterns
- Error handling — never surface DB errors to the user; log them instead
Testing
# Quick test with sqlmap (on systems you own)
sqlmap -u 'https://example.com/api/user?id=1' --batch --level=3
# Automated scans with OWASP ZAP
# Manual testing in Burp Suite
# SAST with semgrep in GitHub Actions
semgrep --config=p/sql-injection .
Conclusion
The SQL injection fix has not changed for 25 years: use parameterized queries. Every framework ships them by default; the only rule is never concatenate strings into SQL. A single line of discipline eliminates the class of bugs that has breached countless companies.
Codebase scan, penetration test report and developer training Contact us