jan-karel.com
Home / Security Measures / Web Security / SQL Injection Prevention

SQL Injection Prevention

SQL Injection Prevention

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 message

Error-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 column

Note 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:

query = "SELECT * FROM users WHERE id = " + request.args.get('id')

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

Op de hoogte blijven?

Ontvang maandelijks cybersecurity-inzichten in je inbox.

← Web Security ← Home