Skip to main content

Free 30-min security demo  — We'll scan your real code and show live findings, no commitment Book Now

Offensive360
Vulnerability Research

SQL Injection Prevention: Developer Guide 2026

How to prevent SQL injection in every language: parameterized queries, ORMs, input validation, and SAST detection for PHP, Python, Java, C# & Node.js.

Offensive360 Security Research Team — min read
SQL injection prevention prevent SQL injection SQL injection fix parameterized queries prepared statements CWE-89 OWASP A03 web security database security SAST injection vulnerabilities secure coding

SQL injection remains the most consistently exploited web application vulnerability — ranked in the OWASP Top 10 every year since the list began, and responsible for a disproportionate share of high-profile data breaches. Despite being well-understood for over two decades, SQL injection still appears regularly in production code, because the patterns that create it are natural and convenient to write.

This guide explains exactly how to prevent SQL injection across every major language and framework, why common “fixes” fail, and how to verify your prevention measures are working.


Why SQL Injection Still Happens in 2026

SQL injection persists for a few predictable reasons:

String building is the natural way to construct queries. When a developer first learns database access, they write "SELECT * FROM users WHERE id = " + userId. This works. It’s readable. And it’s completely vulnerable.

Sanitization feels like a fix but isn’t. The intuitive response to SQL injection is to sanitize or escape the input before embedding it in a query. This is the wrong mental model — sanitization is fragile, context-dependent, and brittle across different database engines.

Frameworks don’t always force the right behavior. Some ORMs make it easy to accidentally drop back to raw SQL with string interpolation. Entity Framework’s FromSqlRaw(), SQLAlchemy’s text(), and Django’s extra() can all introduce injection if used carelessly.

Second-order injection is invisible to most developers. Data that’s stored safely in one request and used in a query in another request bypasses the one-request-at-a-time mental model developers use to reason about SQL injection. See the second-order SQL injection guide for the full breakdown.


The Only Reliable Fix: Parameterized Queries

The definitive way to prevent SQL injection is parameterized queries — also called prepared statements. The mechanism is simple:

Instead of building a SQL string and then executing it, you write the SQL query with parameter placeholders, then pass the values as separate arguments. The database driver handles the substitution, ensuring that parameter values are treated as data — never as SQL syntax — regardless of what characters they contain.

-- VULNERABLE: SQL built as a string
SELECT * FROM users WHERE username = 'admin' OR '1'='1'

-- SECURE: SQL and data are separate
SELECT * FROM users WHERE username = ?
-- Parameter passed separately: "admin' OR '1'='1"
-- Treated as a literal string — the ' characters cannot break out of the value

When parameterization is used correctly, there is no string the attacker can craft that will modify the SQL structure. The query shape is fixed; only the data values change.


Parameterized Queries by Language

PHP — PDO and MySQLi

PHP has two parameterized query interfaces. PDO (PHP Data Objects) is the preferred approach for new code because it works with multiple database backends:

// VULNERABLE — string concatenation
$username = $_GET['username'];
$query = "SELECT * FROM users WHERE username = '$username'";
$result = mysqli_query($conn, $query);
// Payload: ' OR '1'='1'-- → returns all users

// SECURE — PDO with named parameters
$pdo = new PDO('mysql:host=localhost;dbname=app', $user, $pass);
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username");
$stmt->execute([':username' => $_GET['username']]);
$result = $stmt->fetchAll();

// SECURE — MySQLi with positional parameters
$stmt = $conn->prepare("SELECT * FROM users WHERE username = ?");
$stmt->bind_param("s", $_GET['username']); // "s" = string type
$stmt->execute();
$result = $stmt->get_result();

Never use mysqli_real_escape_string() as your primary defense. It’s context-dependent, bypass-prone, and does not work correctly in all character set configurations. Parameterized queries are the only reliable approach.

Python — psycopg2, SQLite3, SQLAlchemy

Python database libraries use %s or ? as parameter placeholders depending on the driver. Always use these placeholders — never use Python string formatting to build queries:

import psycopg2

# VULNERABLE — f-string or % formatting builds SQL as a string
user_id = request.args.get('id')
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")

# ALSO VULNERABLE — Python's % string formatting
cursor.execute("SELECT * FROM users WHERE id = %s" % user_id)
# The % here is Python string formatting, NOT a SQL parameter

# SECURE — psycopg2 parameterization (second argument is a tuple)
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
# The %s here is a SQL placeholder, substituted by psycopg2 — safe

# SECURE — SQLAlchemy ORM (auto-parameterized)
user = session.query(User).filter(User.id == user_id).first()

# SECURE — SQLAlchemy Core with bound parameters
from sqlalchemy import text
stmt = text("SELECT * FROM users WHERE id = :id")
result = connection.execute(stmt, {"id": user_id})

The critical distinction in Python: cursor.execute("... %s" % value) is Python string interpolation — vulnerable. cursor.execute("... %s", (value,)) is driver parameterization — safe. The visual difference is subtle but the security difference is total.

Java — JDBC PreparedStatement

In Java, use PreparedStatement instead of Statement.executeQuery() with a concatenated string:

// VULNERABLE — Statement with string concatenation
String username = request.getParameter("username");
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
    "SELECT * FROM users WHERE username = '" + username + "'"
);

// SECURE — PreparedStatement with parameter placeholders
String username = request.getParameter("username");
PreparedStatement stmt = conn.prepareStatement(
    "SELECT * FROM users WHERE username = ?"
);
stmt.setString(1, username); // 1-indexed, sets parameter 1
ResultSet rs = stmt.executeQuery();

For multiple parameters:

PreparedStatement stmt = conn.prepareStatement(
    "UPDATE users SET password = ? WHERE username = ? AND active = ?"
);
stmt.setString(1, newPasswordHash);
stmt.setString(2, username);
stmt.setBoolean(3, true);
stmt.executeUpdate();

Hibernate and Spring Data JPA parameterize automatically when you use JPQL queries or the @Query annotation with named parameters:

// Spring Data JPA — automatic parameterization
@Query("SELECT u FROM User u WHERE u.username = :username")
User findByUsername(@Param("username") String username);

// JPQL with named parameters — safe
Query query = entityManager.createQuery(
    "SELECT u FROM User u WHERE u.username = :username"
);
query.setParameter("username", username);

C# / .NET — SqlCommand and Entity Framework

ADO.NET SqlCommand uses named parameters with @:

// VULNERABLE — string interpolation or concatenation
string username = Request.QueryString["username"];
string sql = $"SELECT * FROM Users WHERE Username = '{username}'";
SqlCommand cmd = new SqlCommand(sql, connection);
cmd.ExecuteReader();

// SECURE — SqlCommand with parameters
string username = Request.QueryString["username"];
string sql = "SELECT * FROM Users WHERE Username = @username";
SqlCommand cmd = new SqlCommand(sql, connection);
cmd.Parameters.AddWithValue("@username", username);
SqlDataReader reader = cmd.ExecuteReader();

// ALSO SECURE — use Add() with explicit type for better performance
cmd.Parameters.Add("@username", SqlDbType.NVarChar, 100).Value = username;

Entity Framework with LINQ is automatically parameterized:

// SECURE — EF LINQ query (auto-parameterized)
var user = dbContext.Users
    .Where(u => u.Username == username)
    .FirstOrDefault();

// VULNERABLE — FromSqlRaw with string interpolation
var user = dbContext.Users
    .FromSqlRaw($"SELECT * FROM Users WHERE Username = '{username}'")
    .FirstOrDefault();

// SECURE — FromSqlInterpolated (uses C# FormattableString — parameterized)
var user = dbContext.Users
    .FromSqlInterpolated($"SELECT * FROM Users WHERE Username = {username}")
    .FirstOrDefault();

The difference between FromSqlRaw() and FromSqlInterpolated() is critical and often missed. Both accept C# interpolated strings, but FromSqlRaw() builds the SQL as a plain string (vulnerable), while FromSqlInterpolated() uses FormattableString to pass parameters safely.

Node.js — mysql2, pg, Sequelize

// VULNERABLE — template literal builds SQL string
const username = req.query.username;
const result = await db.query(`SELECT * FROM users WHERE username = '${username}'`);

// SECURE — mysql2 with ? placeholders
const [rows] = await db.execute(
    'SELECT * FROM users WHERE username = ?',
    [username] // Values passed as array
);

// SECURE — pg (PostgreSQL) with $1 placeholders
const result = await client.query(
    'SELECT * FROM users WHERE username = $1',
    [username]
);

// SECURE — Sequelize ORM (auto-parameterized)
const user = await User.findOne({ where: { username } });

// SECURE — Sequelize raw query with replacements
const users = await sequelize.query(
    'SELECT * FROM users WHERE username = :username',
    { replacements: { username }, type: QueryTypes.SELECT }
);

Note the difference between Sequelize’s replacements (parameterized, safe) and using template literals with sequelize.query (vulnerable).


What About Stored Procedures?

Stored procedures can reduce SQL injection risk, but they do not eliminate it. A stored procedure that builds dynamic SQL internally using string concatenation is just as vulnerable:

-- VULNERABLE stored procedure (SQL Server)
CREATE PROCEDURE GetUser
    @Username NVARCHAR(100)
AS
BEGIN
    -- Dynamic SQL with concatenation — still injectable
    DECLARE @sql NVARCHAR(500)
    SET @sql = N'SELECT * FROM Users WHERE Username = ''' + @Username + ''''
    EXEC sp_executesql @sql
END
-- SECURE stored procedure — direct parameterized query
CREATE PROCEDURE GetUser
    @Username NVARCHAR(100)
AS
BEGIN
    -- No dynamic SQL — parameterization implicit
    SELECT * FROM Users WHERE Username = @Username
END

-- OR: if dynamic SQL is truly necessary, parameterize it
DECLARE @sql NVARCHAR(500)
SET @sql = N'SELECT * FROM Users WHERE Username = @uname'
EXEC sp_executesql @sql, N'@uname NVARCHAR(100)', @uname = @Username

Calling a stored procedure from application code does not automatically make it safe. Audit stored procedures for dynamic SQL construction the same way you audit application code.


Allowlist Input Validation as Defense-in-Depth

Parameterization is the primary defense. Input validation adds a second layer that limits what values can reach the database at all:

import re

def get_user_by_id(user_id: str):
    # Allowlist: user IDs must be positive integers
    if not re.match(r'^\d+$', user_id):
        raise ValueError("Invalid user ID format")

    user_id_int = int(user_id)
    cursor.execute("SELECT * FROM users WHERE id = %s", (user_id_int,))
    return cursor.fetchone()

Allowlist validation (accepting only known-good values) is far stronger than blocklist sanitization (trying to remove known-bad characters). Blocklists are always incomplete — there are too many ways to encode SQL metacharacters to reliably block them all.

For specific data types:

  • Numeric IDs: parse to integer — if it raises an exception, reject the input
  • Enum values: compare against a fixed set of allowed values before use
  • Dates: parse to a date type and reformat — never embed the raw string
  • Usernames/search terms: apply length limits and character restrictions, then parameterize

Validation reduces the attack surface; parameterization ensures that whatever passes validation cannot cause injection. Use both.


Preventing Second-Order SQL Injection

Second-order SQL injection requires one additional rule: treat data retrieved from your own database as untrusted when building SQL queries.

# STAGE 1: Registration (safe storage)
cursor.execute("INSERT INTO users (username) VALUES (%s)", (username,))
# Stores: admin'-- safely

# STAGE 2 (different request): Password change
cursor.execute("SELECT username FROM users WHERE id = %s", (user_id,))
username = cursor.fetchone()[0]  # Retrieves: admin'--

# VULNERABLE: "trusted" DB data used in a new query without parameterization
cursor.execute(
    "UPDATE users SET password = '" + new_hash + "' WHERE username = '" + username + "'"
)
# Executes: UPDATE users SET password='...' WHERE username = 'admin'--'
# The -- comments out the WHERE clause → updates ALL users

# SECURE: parameterize every query, regardless of data source
cursor.execute(
    "UPDATE users SET password = %s WHERE username = %s",
    (new_hash, username)  # DB-sourced values still get parameterized
)

The rule is absolute: no value — regardless of whether it came from an HTTP request, the database, session storage, or a config file — should ever be concatenated into a SQL query string. Every value that touches a SQL query must go through a parameter placeholder.


Least-Privilege Database Accounts

Even with parameterized queries throughout your codebase, establishing database least-privilege limits the blast radius if injection somehow occurs:

-- Create an application-specific user with minimum required permissions
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'StrongRandomPassword!';

-- Grant only what the application actually needs
GRANT SELECT, INSERT, UPDATE ON app_db.users TO 'appuser'@'localhost';
GRANT SELECT, INSERT ON app_db.orders TO 'appuser'@'localhost';

-- Never grant DROP, ALTER, GRANT, or FILE to application users
-- Never use root or a DBA account as the application database user
FLUSH PRIVILEGES;

If an attacker does find an injection point, a least-privilege account restricts them to the tables and operations your application legitimately uses — preventing DROP TABLE attacks, cross-database access, and file read/write operations.


Using SAST to Find SQL Injection

Manual code review can find obvious SQL injection, but it doesn’t scale across large codebases with complex data flows. Static Application Security Testing (SAST) tools with taint analysis automate this detection by tracing every path from user input to SQL query construction.

A good SAST tool will find:

  • Direct injection: user input concatenated into a query in the same function
  • Indirect injection: user input passed through transformation functions before reaching a query
  • Second-order injection: data written to the database in one code path and used unsafely in a query in another
  • ORM-specific patterns: FromSqlRaw() with string interpolation, SQLAlchemy text() with .format(), Sequelize raw queries with template literals

Offensive360 SAST detects SQL injection across 60+ languages with interprocedural taint analysis — tracing taint across function calls, class boundaries, and database read/write operations. Findings include the full data flow from source to sink, making them immediately actionable for developers.

[CRITICAL] SQL Injection (Second-Order)
  Source: UserRegistrationController.java:45 → request.getParameter("username")
  Storage: UserRegistrationController.java:58 → INSERT INTO users
  Retrieval: PasswordChangeService.java:23 → SELECT username FROM users
  Sink: PasswordChangeService.java:31 → stmt.executeQuery("... WHERE username = '" + username + "'")
  CWE: CWE-89 | OWASP: A03:2021
  Fix: Replace string concatenation with PreparedStatement parameter binding

SQL Injection Prevention Checklist

Use this checklist to audit your codebase:

Application Code:

  • Every SQL query uses parameterized placeholders — no string concatenation or template literals
  • ORM raw query methods (FromSqlRaw, execute(), raw_query()) are audited for string building
  • Data from the database is treated as untrusted when building SQL (second-order prevention)
  • Stored procedures do not contain dynamic SQL built from concatenation
  • EXEC / sp_executesql calls with string building use parameterized form

Input Handling:

  • Numeric parameters are parsed to integer type before use
  • Allowlist validation applied to enum-like parameters
  • Input length limits enforced at the application layer

Database Configuration:

  • Application database user has only SELECT/INSERT/UPDATE on required tables
  • Application user cannot DROP, ALTER, GRANT, or access system tables
  • Multiple database users used for read-only and read-write operations where appropriate

Testing:

  • SAST scan run against all code that touches database queries
  • DAST scan run against staging environment targeting login, search, profile, and admin endpoints
  • Second-order injection paths tested manually (register with payload, trigger password reset)

Summary

SQL injection prevention reduces to a single rule: never build SQL queries by concatenating strings. Use parameterized queries everywhere — for every database operation, in every code path, whether the data comes from an HTTP request, the database, a file, or any other source.

The rest — input validation, stored procedure auditing, least-privilege database accounts, SAST scanning — is defense-in-depth that limits the impact of any injection that slips through. But parameterization alone, applied consistently, eliminates the entire SQL injection vulnerability class.

If you have an existing codebase and want to understand your current SQL injection exposure, a one-time SAST scan ($500) provides a complete taint-analysis report with every SQL injection path mapped from source to sink — including second-order paths that cross function and file boundaries.


Offensive360 SAST detects SQL injection, second-order SQL injection, and the full OWASP Top 10 injection classes across 60+ languages. Scan your codebase or book a demo to see taint analysis on your own code.

Offensive360 Security Research Team

Application Security Research

Find vulnerabilities before attackers do

Run Offensive360 SAST and DAST against your applications and get a full vulnerability report in minutes.