What SQL Injection Actually Is

SQL injection (SQLi) happens when user-supplied input is embedded directly into a SQL query without proper sanitisation. The database can't distinguish between the developer's intended query structure and the attacker's appended commands β€” so it executes both.

It sounds almost too simple to still be a problem in 2026. And yet, in the last twelve months alone, major breaches at financial services firms, healthcare providers, and e-commerce platforms have traced back to unsanitised query strings. The vulnerability is simple to introduce and surprisingly easy to miss in code review.

OWASP context: SQL injection falls under "Injection" β€” the third most critical category in the OWASP Top 10 2021. It's been on that list in some form since OWASP started publishing it in 2003.

How the Attack Works

Consider a basic login form. The backend takes the username and password and constructs a query to check credentials:

login.py Python
# Vulnerable β€” string concatenation
def check_login(username, password):
    query = f"SELECT * FROM users WHERE username='{username}' AND password='{password}'"
    result = db.execute(query)
    return result.fetchone()

A normal user enters alice and their password. The query works fine. An attacker enters admin'-- as the username. The query becomes:

injected query SQL
-- What the attacker made the DB execute:
SELECT * FROM users WHERE username='admin'--' AND password=''

-- The -- comments out the password check entirely.
-- The attacker is now logged in as admin with no password.

That's login bypass in two characters. No exploit kit. No zero-day. Just a quote and two dashes.

We've seen this in production: During a security audit of a SaaS platform, we found this exact pattern in a legacy authentication endpoint that had survived three refactors. The developers assumed the ORM protected them β€” it didn't, because one engineer had written a raw query for "performance reasons" five years earlier.

Types of SQL Injection

Not all SQLi looks the same. Understanding the variants helps you test more thoroughly and understand why some attacks are harder to detect than others.

In-band SQLi (Classic)

The attacker gets results back through the same channel as the attack. This includes error-based SQLi (database error messages reveal schema information) and union-based SQLi (UNION SELECT pulls data from other tables into the response).

Blind SQLi

The application doesn't return query results or error messages β€” but behaviour changes based on the injected condition. Boolean-based blind SQLi works by asking true/false questions: AND 1=1 vs AND 1=2 produces different page responses, letting the attacker infer data one bit at a time.

Time-based Blind SQLi

When even behavioural differences are absent, attackers use time delays. Injecting '; WAITFOR DELAY '0:0:5'-- on SQL Server or AND SLEEP(5) on MySQL causes the database to pause. A 5-second response confirms the injection point exists β€” and data can be exfiltrated character by character through timing.

Out-of-band SQLi

The attacker uses the database's outbound network capabilities (DNS lookups, HTTP requests) to exfiltrate data to an external server they control. Rare but devastating when the other methods are blocked.

Automated tools handle all of this: sqlmap, ghauri, and similar tools can test all injection types, enumerate tables, dump data, and even attempt privilege escalation β€” fully automatically. If a SQLi point exists, a motivated attacker will find it quickly.

Real-World Impact

What can an attacker actually do with SQL injection? More than most developers imagine.

  • Data theft: Dump entire user tables including hashed passwords, PII, financial records, and private messages
  • Authentication bypass: Log in as any user, including admins, without knowing their password
  • Data modification: UPDATE or DELETE records β€” overwrite order histories, change account balances, corrupt data
  • Privilege escalation: On MySQL with FILE privilege, read server files. On SQL Server with xp_cmdshell, execute OS commands
  • Persistence: Create new admin accounts, plant backdoors in stored procedures

The 2021 breach of a major credit bureau β€” affecting 700 million records β€” traced back to SQL injection in an API endpoint that had been in production for three years. The data was on the dark web before the company knew the breach had occurred.

Parameterized Queries: The Real Fix

The correct fix is parameterized queries (also called prepared statements). Instead of building the SQL string with user input embedded, you pass the query structure separately from the data. The database driver handles the escaping β€” correctly, every time.

login_fixed.py Python
# Fixed β€” parameterized query
def check_login(username, password):
    query = "SELECT * FROM users WHERE username = %s AND password = %s"
    result = db.execute(query, (username, password))
    return result.fetchone()

# Node.js equivalent
const query = "SELECT * FROM users WHERE username = ? AND password = ?";
db.execute(query, [username, password]);

With parameterized queries, the attacker's admin'-- input is treated as a literal string β€” it's passed to the database as data, not as SQL syntax. The injection has no effect.

It's also faster: Parameterized queries allow the database to cache query execution plans. For frequently-run queries, this gives you a performance improvement alongside the security fix.

ORMs and Why They're Not Enough

Modern frameworks β€” Django ORM, SQLAlchemy, Hibernate, ActiveRecord β€” use parameterized queries under the hood. If you use them correctly, you're largely protected. The problem is "correctly."

Raw query escape hatches

Every ORM has a raw query escape hatch for when developers need more control. These are SQLi waiting to happen:

django_vulnerable.py Python
# Django ORM β€” safe
User.objects.filter(username=username)

# Django raw() β€” vulnerable if you interpolate
User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{username}'")

# Django extra() β€” deprecated but still in many codebases, vulnerable
User.objects.extra(where=[f"username = '{username}'"])

In our experience, roughly one in three large Django codebases has at least one raw query with unsafe interpolation, usually buried in a reporting module or added years ago for a "one-off" requirement.

Dynamic order-by and column selection

Parameterized queries protect values but not identifiers β€” column names, table names, ORDER BY direction. If these come from user input, you need explicit allowlisting:

sort_handler.py Python
# Vulnerable β€” user controls ORDER BY column
query = f"SELECT * FROM products ORDER BY {user_sort_field}"

# Fixed β€” allowlist of permitted sort columns
ALLOWED_SORT = {"name", "price", "created_at"}
if user_sort_field not in ALLOWED_SORT:
    raise ValueError("Invalid sort field")
query = f"SELECT * FROM products ORDER BY {user_sort_field}"  # now safe

WAF Limitations

Web Application Firewalls can block common SQL injection payloads. They're useful as a defence-in-depth layer but should never be your primary control.

  • WAFs are bypassable: Encoding tricks (%27 for '), comment variants (/*!*/), and case manipulation regularly evade WAF signatures. A determined attacker will test bypass techniques systematically.
  • WAFs don't see encrypted payloads: If your app processes encrypted or encoded data and decodes it before passing to SQL, the WAF sees only the ciphertext.
  • False sense of security: Teams that rely on WAFs often deprioritise fixing the underlying vulnerability. When the WAF signature misses a novel bypass, there's nothing else standing between the attacker and the database.

WAFs buy time β€” fix the code: Use WAFs as a compensating control while you remediate, not as a permanent substitute for parameterized queries.

Detecting SQL Injection with SAST

Static Application Security Testing (SAST) tools analyse source code for SQL injection patterns without executing the code. They trace data flow from input sources (HTTP parameters, headers, cookies) to sink functions (database query execution).

A good SAST tool will catch:

  • String concatenation in query construction with tainted input
  • f-string / format string interpolation into SQL strings
  • ORM raw query methods with user-controlled values
  • Dynamic identifier injection (ORDER BY, table names)

SAST has limits β€” it can generate false positives and miss complex multi-step taint flows. But for the most common SQLi patterns, it's the fastest way to find them at scale across a large codebase. Running SAST in CI means every pull request gets checked before code reaches production.

SQL Injection Prevention Checklist

  1. Use parameterized queries everywhere β€” no exceptions, no "just this once for the reporting page"
  2. Audit raw query usage β€” grep for raw(, execute(, query( and review each one
  3. Allowlist dynamic identifiers β€” if user input controls column/table names or ORDER BY, validate against an explicit allowlist
  4. Apply least privilege to DB accounts β€” your app's DB user shouldn't have DROP TABLE, FILE, or xp_cmdshell access
  5. Enable SAST in CI β€” catch new injections before they merge
  6. Run periodic DAST β€” test running applications for SQLi with tools like sqlmap in a staging environment
  7. Don't expose database errors to users β€” generic error messages prevent error-based information leakage
  8. Review ORM upgrade notes β€” sometimes ORM versions change how raw queries are handled

Scan Your Codebase for SQL Injection

AquilaX SAST traces tainted data flow to database sinks across your entire codebase β€” including raw query patterns your ORM doesn't protect.

Start Free Scan