SQL Injection in 2026: Still a Problem, Still Preventable


SQL injection has been on the OWASP Top 10 since the list was created in 2003. It’s one of the most documented vulnerabilities in existence, the fix is well-known, and it’s still responsible for major data breaches. The persistence isn’t because the problem is hard to solve - it’s because developers continuously introduce it in new code without recognizing it.

Understanding why SQL injection works, not just that it’s bad, makes it impossible to introduce accidentally.

How It Works

SQL injection happens when user-supplied data is interpolated directly into a SQL query. The database receives both data and SQL syntax in the same string and can’t tell which is which.

The classic example:

# Vulnerable
def get_user(username):
    query = f"SELECT * FROM users WHERE username = '{username}'"
    return db.execute(query)

With normal input (alice), the query is:

SELECT * FROM users WHERE username = 'alice'

With malicious input (' OR '1'='1):

SELECT * FROM users WHERE username = '' OR '1'='1'

'1'='1' is always true. The WHERE clause now matches all rows. Every user in the database is returned.

Worse, with input ('; DROP TABLE users; --):

SELECT * FROM users WHERE username = ''; DROP TABLE users; --'

(Whether this works depends on whether the library allows multiple statements, but the principle holds.)

The database engine is executing exactly what it received. It has no way to know that OR '1'='1' was supposed to be data, not SQL.

Beyond Authentication Bypass

Authentication bypass (“login without a valid password”) is the textbook example, but SQL injection has a wider attack surface:

Data exfiltration with UNION: if the attacker can add a UNION clause, they can extract data from other tables:

-- Input: ' UNION SELECT username, password FROM users --
SELECT name, price FROM products WHERE id = '' UNION SELECT username, password FROM users --'

Blind SQL injection: the application doesn’t return query results directly, but the attacker can infer information through boolean differences in the response:

' AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin')='a

If the response differs from a failed condition, the first character of the admin password is ‘a’. By repeating this for each character position, the entire password can be extracted.

This works even on pages that just say “found” or “not found” - and even when error messages are suppressed.

Time-based blind injection: if even response differences are suppressed, delay-based techniques work:

-- If the first character of admin's password is 'a', wait 5 seconds
'; IF (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin')='a' WAITFOR DELAY '0:0:5' --

The attacker observes the response time. No data in the response needed.

Second-order injection: data is stored safely (parameterized) but then retrieved and used unsafely in a later query. A username containing SQL is stored correctly but later inserted into a new query without parameterization.

The Fix: Parameterized Queries

The complete and reliable fix is to never concatenate user input into SQL strings. Use parameterized queries (also called prepared statements).

# Vulnerable - string interpolation
query = f"SELECT * FROM users WHERE email = '{email}'"

# Safe - parameterized
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))

With a parameterized query, the database driver sends the SQL template and the parameters separately. The database receives them separately. There is no step where user input is interpreted as SQL. The %s is a placeholder; whatever string is passed as the parameter is treated as a literal string value, not SQL.

This cannot be bypassed by any input value. Even if the parameter contains SQL syntax, it’s always treated as data.

Examples in various stacks:

// Node.js with pg (PostgreSQL)
const result = await db.query(
    'SELECT * FROM users WHERE email = $1',
    [email]
);

// Python with SQLAlchemy ORM (parameters handled automatically)
user = session.query(User).filter(User.email == email).first()

// Java with JDBC
PreparedStatement stmt = conn.prepareStatement(
    "SELECT * FROM users WHERE email = ?"
);
stmt.setString(1, email);
ResultSet rs = stmt.executeQuery();

// Go with database/sql
row := db.QueryRow("SELECT * FROM users WHERE email = $1", email)

All ORMs in common use (SQLAlchemy, Django ORM, ActiveRecord, Hibernate, GORM) parameterize queries when you use their standard query APIs. The vulnerability appears when you bypass the ORM with raw SQL:

# Dangerous - raw SQL with string formatting
User.objects.raw(f"SELECT * FROM users WHERE username = '{username}'")

# Safe - raw SQL with parameters
User.objects.raw("SELECT * FROM users WHERE username = %s", [username])

What About Sanitization?

Escaping or sanitizing user input instead of parameterizing is the wrong approach. It’s attempted, but it’s fundamentally brittle.

Escaping tries to transform dangerous input into safe input. This requires:

  • Correctly identifying every dangerous character in every SQL context
  • Never missing a code path where unsanitized input reaches a query
  • Handling every database’s escaping rules correctly

Parameterization doesn’t try to make unsafe input safe. It makes the structure of the query immutable. The user’s input can’t change the query structure because it’s never part of the SQL text.

The historical record of sanitization-based defenses is not good. Encoding errors, context switches, multi-byte character attacks, and missed code paths have all been exploited in “sanitized” applications.

ORMs Are Not a Guarantee

A common misconception: “we use an ORM, so we’re safe from SQL injection.” ORMs parameterize by default, but they don’t prevent you from writing vulnerable code.

The dangerous patterns in ORM-based code:

String formatting in queries:

# Django ORM - vulnerable
User.objects.filter(username__regex=f"^{user_input}")  # regex is interpolated
User.objects.extra(where=[f"name = '{user_input}'"])    # extra() is unsafe

Order-by from user input:

# If sort_field is user-controlled, this is a SQL injection vector
# even though it looks like ORM usage
qs = User.objects.order_by(sort_field)

Validate that sort_field is one of an allowed set of column names before passing it to the ORM.

Raw queries with format strings: shown above. Always use parameter bindings.

Testing for It

Simple manual test: add a single quote to any input field and observe the response. If you get a database error, you likely have an injection vulnerability. If you get a normal response (or a 400), the input is being handled safely.

Automated tools (SQLMap, Burp Suite’s scanner) can test for SQL injection systematically. Running these against your own application in a development environment is a reasonable part of a security review.

The fix is consistently applied in one place: use parameterized queries everywhere user-controlled data reaches SQL. No exceptions, no special cases, no “this input is from a trusted source.” Trust the data layer to handle parameterization, not individual engineers to remember to sanitize.



Read more