Skip to main content

Command Palette

Search for a command to run...

SQL Injection Patterns That Still Slip Through Code Review in 2026

*SQL injection is older than most engineers reading this. It is also still the third most common web application vulnerability according to OWASP's latest findings. Here is why it keeps shipping and what the patterns look like in modern codebases.*

Updated
7 min read
SQL Injection Patterns That Still Slip Through Code Review in 2026
M
Backend engineer with a focus on SQL performance, database reliability, and the kind of bugs that only show up at 2am on a Saturday. I spent 18 months building SlowQL, an open source SQL static analyzer with 171 rules across security, performance, reliability, compliance, quality and cost. Zero dependencies. Completely offline. Built after watching one SELECT * on a 10 million row table take down production for an entire weekend. I write about the SQL patterns that look fine in review and destroy you in production. Real incidents, real root causes, and how to catch them before they ship. If you work with databases at scale you've probably already seen most of what I write about. Hopefully a bit earlier than I did.

The numbers are worse than you think

SQL injection is not a legacy problem. It is an active one.

In 2024, over 2,400 SQL injection CVEs were filed in open source projects alone. In closed source applications, SQL injection accounts for roughly 10% of all discovered vulnerabilities. For organizations that scan their codebases for the first time, over 20% are found vulnerable, with an average of nearly 30 separate injection points per affected codebase.

The reason it keeps shipping is not that engineers don't know what SQL injection is. It's that the patterns that survive code review don't look like textbook injection. They look like normal code.


Pattern 1: String concatenation hiding in plain sight

The classic case that every engineer knows:

-- Never do this
query = "SELECT * FROM users WHERE email = '" + email + "'"

This gets caught in review. What doesn't get caught is the same pattern in a less obvious context:

# This slips through constantly
def get_report(table_name, start_date, end_date):
    query = f"""
        SELECT * FROM {table_name}
        WHERE created_at BETWEEN '{start_date}' AND '{end_date}'
    """
    return db.execute(query)

The table_name parameter cannot be parameterized in most databases. Table and column names are identifiers, not values, so you cannot use a prepared statement placeholder for them. Engineers reach for f-strings and move on.

The fix is an allowlist:

ALLOWED_TABLES = {'orders', 'users', 'products', 'events'}

def get_report(table_name, start_date, end_date):
    if table_name not in ALLOWED_TABLES:
        raise ValueError(f"Invalid table: {table_name}")
    query = f"SELECT * FROM {table_name} WHERE created_at BETWEEN %s AND %s"
    return db.execute(query, (start_date, end_date))

Pattern 2: Dynamic ORDER BY clauses

This one passes review almost every time. Sort fields and directions come from request parameters and get interpolated directly into the query because column names and sort directions cannot use parameterized placeholders:

# The sort_field comes from a request parameter
def get_users(sort_field, sort_direction):
    query = f"""
        SELECT id, name, email FROM users
        ORDER BY {sort_field} {sort_direction}
    """
    return db.execute(query)

An attacker who controls sort_field or sort_direction can append arbitrary SQL to the query. The fix is always an allowlist, never sanitization:

ALLOWED_SORT_FIELDS = {'id', 'name', 'created_at', 'email'}
ALLOWED_DIRECTIONS = {'ASC', 'DESC'}

def get_users(sort_field, sort_direction):
    if sort_field not in ALLOWED_SORT_FIELDS:
        sort_field = 'id'
    if sort_direction not in ALLOWED_DIRECTIONS:
        sort_direction = 'ASC'
    query = f"SELECT id, name, email FROM users ORDER BY {sort_field} {sort_direction}"
    return db.execute(query)

Pattern 3: Second-order injection

First-order injection is caught by most code review. Second-order injection is caught by almost none.

Second-order injection happens when user input is safely stored in the database using parameterized queries but then retrieved and used later in a dynamic query without re-validation:

# Step 1: User registers safely using parameterized query
# Looks completely safe in review
db.execute("INSERT INTO users (username) VALUES (%s)", (username,))

# Step 2: Later, the stored username is used in a dynamic query
# In a completely different part of the codebase
user = db.execute("SELECT username FROM users WHERE id = %s", (user_id,))
report_query = f"SELECT * FROM orders WHERE customer = '{user['username']}'"

The dangerous query is in a completely different part of the codebase from where the data entered. The data looked safe when it was stored. Nobody connects the dots in review because the vulnerability spans multiple files and multiple operations.

SlowQL flags this as SEC-INJ-005: Second-Order SQL Injection Risk, detecting dynamic SQL construction that uses values sourced from database reads rather than direct user input.


Pattern 4: ORM escape hatches

Modern ORMs provide parameterized queries by default. The injection risk comes from the escape hatches engineers reach for when the ORM cannot handle a complex query.

Django's RawSQL, SQLAlchemy's text(), ActiveRecord's find_by_sql, all of them bypass the ORM's safety mechanisms. They exist for legitimate reasons. They also introduce injection risk the moment a variable touches the query string:

# Django — looks like ORM, behaves like raw SQL
from django.db.models.expressions import RawSQL

# Safe — value passed as parameter
User.objects.annotate(val=RawSQL("age + %s", (10,)))

# Dangerous — variable interpolated directly into SQL string
User.objects.annotate(val=RawSQL(f"age + {user_input}", ()))

The distinction between the safe and dangerous form is subtle enough that it survives review regularly. Django itself had a SQL injection CVE in 2024 (CVE-2024-42005) related to improper handling of certain query patterns.


Pattern 5: Tautological conditions

Tautological injection manipulates query logic in ways that bypass business rules rather than exfiltrating data directly. A WHERE clause that is always true regardless of the input values returns every row in the table.

In a multi-tenant system this means one customer seeing another customer's data. In an authorization check it means bypassing access controls entirely. The pattern is old but what keeps it alive in 2026 is legacy code, rushed migrations, and the parts of the codebase that have not been touched since the ORM was introduced.

SlowQL detects this as SEC-INJ-003: Tautological OR Condition, flagging WHERE clauses that contain conditions structured to evaluate as always true regardless of input values.


Pattern 6: Hardcoded credentials in migration files

Not injection in the traditional sense but a SQL security pattern that ships through review constantly:

-- In a migration file committed to version control
INSERT INTO admin_users (username, password, role)
VALUES ('admin', 'initialpassword', 'superadmin');

Migration files live in version control permanently. Credentials committed in a migration file years ago are still there today, visible to every engineer who has ever cloned the repository and to anyone who gains read access to the codebase.

SlowQL flags this as SEC-AUTH-001: Hardcoded Password and SEC-CONFIG-001: Hardcoded Database Credentials, scanning SQL files for credential patterns before they ever leave the developer's machine.


Why these keep shipping in 2026

Code review is done by humans reading logic, not humans auditing injection surfaces. A reviewer focused on business logic, architecture and test coverage is not systematically checking whether every dynamic query component is properly validated.

The patterns above do not look dangerous. They look like normal code solving real problems. That is why they survive review.

The fix at the process level is adding a static analysis step to CI that runs before code review, not instead of it. Static analysis does not replace understanding, it catches the patterns that humans consistently miss under time pressure.

SlowQL runs against your SQL files and flags injection patterns before they merge. It catches dynamic SQL construction, tautological conditions, second-order injection risks, hardcoded credentials and more across 45 security rules. It runs completely offline so your SQL never leaves your machine, which matters in environments where sending code to an external service is not an option.

pip install slowql
slowql --input-file sql/ --fail-on high

SQL injection is not going away. The 2024 MOVEit breach, the 2025 VMware Avi Load Balancer CVE (CVE-2025-22217), the ongoing GambleForce campaign targeting organizations across multiple industries, all SQL injection. The patterns are known. The tooling to catch them statically exists. The gap is putting that tooling in the pipeline before code ships.


SlowQL is open source and available on GitHub, PyPI and Docker Hub. If you have seen injection patterns slip through review that are not covered here, open an issue or start a discussion on GitHub.