SQL injection remains in the OWASP Top 10 despite decades of awareness. The 2023-2024 period saw high-profile breaches in healthcare, e-commerce, and government sectors involving SQLi. While the classic ' OR 1=1 -- attack is well-known, modern variants include second-order injection, blind SQLi (time-based and boolean-based), and out-of-band exfiltration. Prevention is well-understood but poorly executed due to legacy code, ORM misuse, and insufficient testing automation.
Parameterized Queries and Prepared Statements
Prepared statements are the gold standard for SQL injection prevention. They separate SQL logic from data at the database engine level, making it impossible for user input to alter query structure.
// Node.js with pg — SAFE
const result = await client.query(
"SELECT * FROM users WHERE email = $1 AND status = $2",
[userEmail, "active"]
);
// Python with psycopg2 — SAFE
cursor.execute(
"INSERT INTO orders (user_id, amount) VALUES (%s, %s)",
[user_id, amount]
);
String interpolation is never safe, even with escaped input. Parameterized queries do not harm query plan caching — most databases cache plans for parameterized queries more effectively than for ad-hoc SQL.
| Language | Safe API | Unsafe Pattern |
|---|---|---|
| Node.js (pg) | client.query(sql, params) | String interpolation with $ |
| Python (psycopg2) | cursor.execute(sql, params) | f-strings or % formatting |
| Java (JDBC) | PreparedStatement | Statement with string concat |
| C# (SqlClient) | SqlCommand with Parameters | SqlCommand with inline SQL |
| PHP (PDO) | PDO::prepare() + execute() | mysqli::query() with concatenation |
Edge cases like dynamic table names or IN clauses with variable-length lists require careful handling. Use allowlisted identifiers for table names and ANY array syntax for dynamic lists.
ORM Protection and Pitfalls
ORMs like Prisma, TypeORM, Sequelize, Django ORM, and Hibernate typically protect against injection by default. However, raw queries, LIKE clauses, and ORDER BY clauses reintroduce risk.
// Prisma — raw query pitfall
const users = await prisma.$queryRawUnsafe(
`SELECT * FROM users WHERE name LIKE '%${searchTerm}%'` // VULNERABLE
);
// Safe alternative with parameter binding
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE name LIKE ${"%" + searchTerm + "%"}
`;
ORDER BY cannot use parameterized columns. Always validate the column name against an allowlist:
const allowedColumns = ["name", "email", "created_at"];
if (!allowedColumns.includes(sortBy)) {
throw new Error("Invalid sort column");
}
const result = await client.query(
`SELECT * FROM users ORDER BY ${sortBy} ${sortDir}`,
);
ORM is not a silver bullet — developers must understand the underlying SQL behavior.
Stored Procedures and Database Hardening
Well-written stored procedures with parameters are safe. However, dynamic SQL inside a procedure using EXEC or EXECUTE IMMEDIATE reintroduces injection risk.
-- SAFE
CREATE PROCEDURE get_user(IN user_id INT)
BEGIN
SELECT * FROM users WHERE id = user_id;
END;
-- VULNERABLE
CREATE PROCEDURE get_user(IN table_name VARCHAR(64))
BEGIN
SET @sql = CONCAT('SELECT * FROM ', table_name);
PREPARE stmt FROM @sql; -- Dynamic SQL inside procedure
EXECUTE stmt;
END;
Apply least-privilege principles: application database users should only have the permissions they need — no DROP, ALTER, or CREATE. Use PostgreSQL row-level security, SQL Server EXECUTE AS, or MySQL SQL SECURITY definer/invoker modes to enforce access at the database layer.
Input Validation and WAF as Defense-in-Depth
Input validation is insufficient alone but essential as a second layer. Prefer whitelist-based validation over blacklist filtering. Type coercion (parsing integers with parseInt), length limits, and Unicode normalization (NFC/NFD) prevent many bypass attempts.
Web Application Firewalls are a delay tactic, not a solution. Attackers bypass WAFs using comment obfuscation (/**/), encoding variations (URL, Unicode, hex, double URL encoding), HTTP parameter pollution, and less-common SQL syntax like BENCHMARK() instead of SLEEP().
-- WAF bypass: comment obfuscation
' UNION/**/SELECT/**/password/**/FROM/**/users--
-- WAF bypass: alternative functions
1 AND BENCHMARK(5000000, MD5('test')) -- time-based blind
NoSQL Injection and Automated Scanning
NoSQL databases are also vulnerable. MongoDB $where clause injection, $regex injection for blind extraction, and JSON query object manipulation all pose risks. The principle is universal: any database language that concatenates user input is vulnerable.
| Database | Injection Vector | Defense |
|---|---|---|
| MongoDB | $where, $regex, $ne | Use $eq, sanitize operators |
| Couchbase | N1QL string concat | Parameterized N1QL |
| Cassandra | CQL string concat | Prepared statements |
| Redis | EVAL/EVALSHA with Lua | Parameterized EVAL |
Shift left with automated scanning. SAST tools like Semgrep, ESLint security plugins, and CodeQL detect injection patterns in pull requests. DAST tools like OWASP ZAP run dynamic scans against running applications.
# GitHub Actions workflow
name: Security Scan
on: pull_request
jobs:
sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npx @neutralinojs/semgrep --config=auto
dast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker run --network host owasp/zap2docker-stable zap-baseline.py -t http://localhost:3000
If prevention fails, detect injection through anomalous query patterns, unexpected errors, and outbound data volume spikes. Rotate credentials immediately, identify the injection point via query log analysis, contain the breach, and preserve forensic evidence.
SQL injection is preventable with consistent application of well-established patterns. Parameterized queries are the primary defense, ORM with raw query awareness provides structural protection, input validation and WAF add secondary layers, and automated scanning in CI/CD verifies compliance. Apply these layers consistently and you eliminate an entire class of vulnerabilities.
