SQL Injection Prevention
SQL Without Sleep Deprivation
Web risk is rarely mysterious. It usually lies in predictable mistakes that persist under time pressure.
For SQL Injection Prevention it is about strict input boundaries, parameterized queries and reviews that stop query risk early.
This makes security less of a separate afterthought and more of a standard quality of your product.
Immediate measures (15 minutes)
Why this matters
The core of SQL Injection Prevention is risk reduction in practice. Technical context supports the choice of measures, but implementation and assurance are central.
Defense: how to do it right
We have now spent an entire chapter breaking things. Let us talk about fixing them for a moment. Because if, after reading all of this, you do not feel a vague nausea about your own code, you have not been paying attention.
1. Parameterized queries -- everywhere, always, without exceptions
This is the only defense that matters. Everything else is a Band-Aid on an amputated leg.
Python (psycopg2 / PyMySQL):
# GOOD
cursor.execute(
"SELECT * FROM users WHERE username = %s AND password = %s",
(username, password)
)
# BAD -- never do this
cursor.execute(
f"SELECT * FROM users WHERE username = '{username}'"
f" AND password = '{password}'"
)Java (JDBC):
// GOOD
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM users WHERE username = ? AND password = ?"
);
ps.setString(1, username);
ps.setString(2, password);
ResultSet rs = ps.executeQuery();
// BAD
Statement s = conn.createStatement();
s.executeQuery(
"SELECT * FROM users WHERE username = '" + username + "'"
);PHP (PDO):
// GOOD
$stmt = $pdo->prepare(
"SELECT * FROM users WHERE username = :user
AND password = :pass"
);
$stmt->execute([':user' => $username, ':pass' => $password]);
// BAD
$pdo->query(
"SELECT * FROM users WHERE username = '$username'"
);C# (.NET):
// GOOD
using var cmd = new SqlCommand(
"SELECT * FROM users WHERE username = @user", conn
);
cmd.Parameters.AddWithValue("@user", username);
// BAD
var cmd = new SqlCommand(
$"SELECT * FROM users WHERE username = '{username}'", conn
);Node.js (with mysql2):
// GOOD
const [rows] = await connection.execute(
'SELECT * FROM users WHERE username = ? AND password = ?',
[username, password]
);
// BAD
const [rows] = await connection.query(
`SELECT * FROM users WHERE username = '${username}'`
);The pattern is always the same: the query has placeholders
(?, %s, :name,
@name) and the values are passed separately. The
database driver ensures that the values are treated as data,
never as code.
2. Using an ORM
Object-Relational Mappers (ORMs) like SQLAlchemy, Hibernate, Entity Framework, and Sequelize generate parameterized queries automatically:
# SQLAlchemy -- automatically parameterized
user = session.query(User).filter_by(
username=username, password=password
).first()But beware: ORMs do not protect you if you use raw SQL:
# This is AGAIN vulnerable, even with SQLAlchemy!
session.execute(
f"SELECT * FROM users WHERE username = '{username}'"
)
# GOOD: raw SQL with parameters in SQLAlchemy
from sqlalchemy import text
session.execute(
text("SELECT * FROM users WHERE username = :user"),
{"user": username}
)3. Least Privilege
The database user that your application connects with should have as few permissions as possible:
-- Create a restricted user
CREATE USER 'webapp'@'localhost' IDENTIFIED BY 'sterkwachtwoord';
-- Grant only the permissions that are needed
GRANT SELECT, INSERT, UPDATE ON shop.products TO 'webapp'@'localhost';
GRANT SELECT, INSERT ON shop.orders TO 'webapp'@'localhost';
-- NOT this:
GRANT ALL PRIVILEGES ON *.* TO 'webapp'@'%';
-- ^^^^ this is the database equivalent of leaving your front door open
-- with a sign "welcome, take what you want"Specifically: - No FILE privilege
(prevents LOAD_FILE / INTO OUTFILE) - No
EXECUTE on system stored procedures (prevents xp_cmdshell)
- No superuser/sa permissions - Restrict to specific
tables and operations
4. Web Application Firewall (WAF)
A WAF is like a bouncer at a club: it stops most unwanted visitors, but a determined attacker in a nice suit still gets through.
WAFs detect known SQL Injection patterns:
# These payloads are blocked by most WAFs:
' OR 1=1-- -
' UNION SELECT
; DROP TABLE
# These maybe not:
'/**/OR/**/1=1--/**/-
' /*!50000UNION*/ /*!50000SELECT*/
' uNiOn SeLeCt
A WAF is an additional defense layer. It is not a replacement for parameterized queries. Relying solely on a WAF is like wearing a bulletproof vest while leaving the door of your house wide open.
5. Error handling
Never pass database error messages through to the end user:
# BAD
try:
cursor.execute(query)
except Exception as e:
return f"Database error: {e}" # attacker sees the error!
# GOOD
try:
cursor.execute(query)
except Exception as e:
logger.error(f"Database error: {e}") # log internally
return "An error has occurred." # generic messageError-based SQL Injection exists only because applications show their error messages. Stop doing that and an entire class of attacks disappears.
6. Input validation (as an extra layer)
Input validation is not your primary defense (that is parameterized queries), but it is a useful extra layer:
import re
def validate_product_id(product_id: str) -> bool:
"""Product ID must be an integer."""
return bool(re.match(r'^\d+$', product_id))
def validate_sort_column(column: str) -> str:
"""Only allowed column names for ORDER BY."""
allowed = {'name', 'price', 'date', 'rating'}
if column not in allowed:
return 'name' # default
return columnNote that ORDER BY cannot be parameterized (it is an
identifier, not a value). Use an allowlist for that.
The uncomfortable truth
Let us be honest for a moment. It is 2026. SQL Injection was discovered in 1998. The first major SQLi attack was in 2008 (Heartland Payment Systems, 130 million credit cards). Bobby Tables -- the XKCD comic that explains SQL Injection -- is from 2007. That is almost twenty years ago.
And yet. Yet there are right now companies running applications in production with code like:
No parameterized queries. No input validation. No WAF. Nothing. Nada.
These are not startups of two students in a garage. These are companies with budgets, with "security teams," with ISO certifications on the wall and a CISO who gives presentations at conferences about "the importance of security by design."
And somewhere in the basement of that building runs a PHP 5.6 application from 2009 that handles the bookkeeping and that nobody dares to touch because "it works and there is no documentation." And that application concatenates strings. In 2026. With direct access to the production database. Which also contains the salaries.
That is no longer a mistake. That is a choice. It is the choice to leave the back door open and then be surprised that someone walks in. It is the choice to buy a smoke detector, not put the battery in, and then sue the smoke detector company when the house burns down.
The patch exists. For more than twenty years. It is a single line of
code. cursor.execute(query, params) instead of
cursor.execute(f"...{user_input}..."). It takes five
minutes to implement. And companies choose not to do it.
Because you know what is more expensive than implementing parameterized queries? Everything except implementing parameterized queries. The pentest that finds the SQLi. The incident response when things go wrong. The legal costs. The fine from the Data Protection Authority. The PR costs to explain why the passwords of 2 million customers are on Pastebin.
But those five minutes to change that one line of code? No. There is no budget for that. It is not in the sprint planning. It has no priority.
Escalation path overview
The following diagram shows how a SQL Injection vulnerability can escalate from a simple data leak to full server and domain control:
Cheat sheet: SQL Injection per database
MySQL / MariaDB
| Action | Payload |
|---|---|
| Version | version() or @@version |
| Current database | database() |
| Current user | current_user() or user() |
| All databases | SELECT schema_name FROM information_schema.schemata |
| All tables | SELECT table_name FROM information_schema.tables WHERE table_schema=database() |
| Columns | SELECT column_name FROM information_schema.columns WHERE table_name='X' |
| String concat | concat(), group_concat(),
concat_ws() |
| Substring | SUBSTRING(str, pos, len) or
MID(str, pos, len) |
| Comment | -- -, #, /* */ |
| Time delay | SLEEP(n) |
| File read | LOAD_FILE('path') |
| File write | INTO OUTFILE 'path' |
| Stacked queries | Yes (with PDO/MySQLi multi_query) |
PostgreSQL
| Action | Payload |
|---|---|
| Version | version() |
| Current database | current_database() |
| Current user | current_user or session_user |
| All databases | SELECT datname FROM pg_database |
| All tables | SELECT table_name FROM information_schema.tables WHERE table_schema='public' |
| String concat | string_agg(), || operator |
| Substring | SUBSTRING(str, pos, len) |
| Comment | -- -, /* */ |
| Time delay | pg_sleep(n) |
| File read | pg_read_file('path'), lo_import() |
| File write | COPY TO, lo_export() |
| Command exec | COPY FROM PROGRAM 'cmd' |
| Quote bypass | CHR(), $$string$$ |
| Stacked queries | Yes |
MSSQL
| Action | Payload |
|---|---|
| Version | @@version |
| Current database | DB_NAME() |
| Current user | SYSTEM_USER or SUSER_SNAME() |
| All databases | SELECT name FROM master..sysdatabases |
| All tables | SELECT name FROM sysobjects WHERE xtype='U' |
| Columns | SELECT name FROM syscolumns WHERE id=OBJECT_ID('tabel') |
| String concat | + operator, FOR XML PATH |
| Substring | SUBSTRING(str, pos, len) |
| Comment | -- -, /* */ |
| Time delay | WAITFOR DELAY '0:0:n' |
| Error leakage | CONVERT(int, data) |
| Command exec | xp_cmdshell 'cmd' |
| UNC trigger | xp_dirtree '\\ip\share' |
| Linked server query | OPENQUERY("server", 'query') |
| Stacked queries | Yes |
Oracle
| Action | Payload |
|---|---|
| Version | SELECT banner FROM v$version |
| Current database | SELECT ora_database_name FROM dual |
| Current user | SELECT user FROM dual |
| All tables | SELECT table_name FROM all_tables |
| Columns | SELECT column_name FROM all_tab_columns WHERE table_name='X' |
| String concat | || operator |
| Substring | SUBSTR(str, pos, len) |
| Comment | --, /* */ |
| Time delay | DBMS_PIPE.RECEIVE_MESSAGE('a', n) |
| Dummy table | dual (required for every SELECT without FROM) |
| Stacked queries | No (in most contexts) |
Further reading
- OWASP Testing Guide - SQL Injection section
- PortSwigger Web Security Academy - SQL Injection labs (free)
- OWASP SQL Injection Prevention Cheat Sheet - defensive implementation guidelines
- PowerUpSQL - https://github.com/NetSPI/PowerUpSQL
- PayloadsAllTheThings - SQL Injection cheatsheets
- HackTricks - SQL Injection section
Further reading in the knowledge base
These articles in the portal give you more background and practical context:
- APIs -- the invisible glue of the internet
- SSL/TLS -- why that padlock in your browser matters
- Encryption -- the art of making things unreadable
- Password hashing -- how websites store your password
- Penetration tests vs. vulnerability scans
You need an account to access the knowledge base. Log in or register.
Related security measures
These articles provide additional context and depth: