Defending Against SQLi
Every technique in this section exists because user input reached the query parser as code. This page is the other side: what stops that, what only slows it down, and what a tester should recommend in the report. It’s ordered by effectiveness - the first control eliminates the class, the rest are defence in depth.
1. Parameterised queries (the actual fix)
Section titled “1. Parameterised queries (the actual fix)”Also called prepared statements. The query structure is sent to the database first, with placeholders; the data is sent separately and can never be reparsed as SQL. This is the only control that removes the vulnerability rather than filtering around it.
// Vulnerable: input concatenated into the query string$q = "SELECT * FROM logins WHERE username='$u' AND password='$p'";
// Fixed: placeholders bound separately$stmt = mysqli_prepare($conn, "SELECT * FROM logins WHERE username=? AND password=?");mysqli_stmt_bind_param($stmt, 'ss', $u, $p);mysqli_stmt_execute($stmt);The equivalent exists in every stack: PDO::prepare, Python DB-API cursor.execute(sql, params), Java PreparedStatement, .NET SqlCommand with Parameters.Add, Go db.Query(sql, args...).
2. Input validation
Section titled “2. Input validation”Reject input that doesn’t match the shape the field requires, server-side, before it reaches any query. Validation is a complement to parameterisation, not a replacement - it narrows the attack surface and catches the identifier case above.
- Allowlist, not blocklist. Define what’s permitted (
^[A-Za-z0-9 ]+$for a port code, a known set for an enum) and reject everything else. Blocklisting characters invites bypasses. - Validate type and range: an
idthat should be a positive integer is cast and bounds-checked, so string payloads never survive. - Do it on the server. Client-side validation is a UX feature and is trivially skipped with a proxy.
3. Least-privilege database account
Section titled “3. Least-privilege database account”Parameterisation stops the injection; least privilege limits the damage if some other sink is missed. The account the web app connects with should hold only the rights the app actually uses.
-- Application account that can only read the one table it needsCREATE USER 'webapp'@'localhost' IDENTIFIED BY '<strong-random>';GRANT SELECT ON shop.products TO 'webapp'@'localhost';- Never connect as
root/sa/ a DBA. Those accounts enable file read/write and command execution; a low-privilege account turns a would-be RCE into a limited data read. - Separate accounts per application and per trust level. Read-only where the app only reads.
- Revoke
FILE(MySQL),pg_read_server_files/pg_execute_server_program(PostgreSQL), and disablexp_cmdshell(MSSQL) unless genuinely required.
4. Controls that only reduce risk
Section titled “4. Controls that only reduce risk”These help, but none closes the hole on its own - treat them as layers, and never as the primary defence in a finding.
- Escaping (
mysqli_real_escape_string,pg_escape_string): escapes quotes so they lose their special meaning. Fragile - it depends on correct charset handling and consistent quoting, and misses numeric contexts where no quotes are involved. Prefer parameterisation. - Web Application Firewall (ModSecurity, cloud WAFs): blocks requests matching known SQLi patterns such as
INFORMATION_SCHEMAorUNION SELECT. Buys time and catches mass scanning, but pattern-based rules are routinely evaded - see Bypasses. A WAF in front of an injectable app is still an injectable app. - Stored procedures: safe only when they themselves use parameterised statements internally; a procedure that concatenates its arguments into dynamic SQL is just as vulnerable.
- ORMs: parameterise by default, which is why they help - but raw-query escape hatches (
.raw(), string-builtWHEREfragments) reintroduce the bug.
For the report
Section titled “For the report”When writing SQLi up, lead with the fix that removes it:
- Primary: convert the affected query (and ideally all queries) to parameterised statements / prepared statements.
- Supporting: server-side allowlist validation on the affected parameters; allowlist any dynamic identifiers.
- Containment: reduce the database account’s privileges to the minimum the application needs; disable file and command primitives.
- Detection: database error monitoring and query logging - error-based blind extraction is quiet in the HTTP response but noisy in DB error logs, so that log is where it shows up.
Avoid recommending “add a WAF” or “escape input” as the main remediation; both are defence in depth behind parameterisation, and presenting either as the fix leaves the client exposed.
Related
Section titled “Related”- Detection - how the vulnerability is found, i.e. what these controls have to stop
- File operations - the privileges section 3 is designed to deny
- Bypasses - why the layer-4 controls can’t be relied on alone