# SQL injection: how it works, how testers prove it, how to fix it in 7 stacks

Source: https://penetrationtestservices.com/vulnerabilities/sql-injection/
Publisher: Excession Labs (penetrationtestservices.com)
Last updated: 2026-11-09

What SQL injection is, a safe demo, real cases with CVE IDs, how testers prove it, and parameterized query fixes for Java, Python, Node.js, PHP, C#, Go and Ruby.

> **Short answer:** SQL injection (SQLi) is a flaw where user input changes the meaning of a database query because the application builds the query as a string. Fix it with parameterized queries, an allow-list for table names and sort order, and a least-privilege database account. Testers prove it with a matched true/false pair or a repeatable timing change, never by dumping data.

SQL injection (SQLi) is a flaw that lets an attacker change the meaning of a database query by sending input that the application treats as SQL. CWE-89 describes it as a product that "constructs all or part of an SQL command using externally-influenced input from an upstream component" and does not neutralize the special characters correctly. PortSwigger adds the impact: an attacker may see data that belongs to other users, and can often change or delete it.

SQL injection testing is part of [web application penetration testing](/web-application-penetration-testing/) and [API penetration testing](/api-penetration-testing/). Testing needs written authorization from the system owner.

## What is SQL injection, in one real query?

SQL injection happens when code joins user input into a query string. The database cannot tell which part the developer wrote and which part the user typed, so it runs all of it as one statement.

Here is the smallest case. A login handler builds this query:

```sql
SELECT id FROM users WHERE email = '' AND password_hash = ''
```

If `` is `alice@example.com`, the query checks one user. If `` contains a quote character, the quote ends the string early and the rest of the input becomes SQL. The query now has a different shape from the one the developer planned. That change of shape is the whole flaw.

OWASP ranks Injection as A05 in the Top 10:2025. It was A03 in 2021. The list covers other interpreters too, such as the browser (see [cross-site scripting](/vulnerabilities/cross-site-scripting/)), the operating system shell (see [OS command injection](/vulnerabilities/os-command-injection/)) and document databases (see [NoSQL injection](/vulnerabilities/nosql-injection/)).

## How does a SQL injection attack work?

An attack sends input that closes the intended value and adds a condition the developer never wrote. The server runs the new query and returns, hides or changes data.

> **Vulnerable product search (fictional app):** This is a demonstration. Harbor Notes is a fictional app on app.example.com, and no real customer system was tested.
> 
> The search endpoint builds its query with string formatting:
> 
> ```python
> # Vulnerable: user input is joined into the SQL text
> @app.get("/api/notes")
> def search():
>     term = request.args["q"]
>     sql = f"SELECT id, title FROM notes WHERE owner_id = 7 AND title LIKE '%{term}%'"
>     return jsonify(db.execute(sql).fetchall())
> ```
> 
> A normal request:
> 
> ```http
> GET /api/notes?q=budget HTTP/1.1
> Host: app.example.com
> ```
> 
> The tester sends two harmless variants that differ only in a true and a false condition, a pair OWASP and PortSwigger both document:
> 
> ```http
> GET /api/notes?q=budget%'+AND+'1'='1 HTTP/1.1
> GET /api/notes?q=budget%'+AND+'1'='2 HTTP/1.1
> ```
> 
> The first returns the same list as the normal request. The second returns an empty list. Both requests are valid SQL, so the application runs them and only the logic differs. That matched pair is the evidence.
The `owner_id = 7` filter in the demo matters. A tester who can alter the `WHERE` logic can often widen the filter and read records that belong to other users. That widening is how this flaw becomes a data breach.

## What are the main types of SQL injection?

The types differ in how the attacker gets the result back. The table lists the four classes and how a tester recognizes each.

| Type | How the result comes back | What a tester looks for |
|---|---|---|
| In-band (error-based, UNION-based) | In the normal response | Database error text, or extra rows in a list |
| Blind (boolean-based) | By a difference in the page or status | A true/false pair with different responses |
| Blind (time-based) | By how long the response takes | A repeatable delay against a baseline |
| Out-of-band | By a network request the database makes | A callback to a host the tester controls |
| Second-order | Input is stored first and used in a query later | A stored value that breaks a different page |

The blind and out-of-band forms are covered in [blind SQL injection](/vulnerabilities/blind-sql-injection/). Second-order injection is easy to miss because the unsafe query sits on a page far from the input form. PortSwigger's academy covers all of these in its SQL injection material.

## Which real cases have dates and CVE IDs?

Two entries in NVD show the range: a 2012 appliance bug and a 2023 file-transfer bug that attackers used at scale.

| CVE | Product | Published in NVD | What the record says |
|---|---|---|---|
| CVE-2012-2574 | Symantec Web Gateway 5.0.x before 5.0.3.18 | 2012-07-23 | SQL injection in the management console |
| CVE-2023-34362 | Progress MOVEit Transfer, versions before the fixed releases | 2023-06-02 | SQL injection that "could allow an unauthenticated attacker to gain access to MOVEit Transfer's database" |

For CVE-2023-34362, NVD records the weakness as CWE-89 and notes exploitation in the wild. The CISA and FBI advisory AA23-158A (released 7 June 2023, updated 16 June 2023) says the CL0P ransomware gang exploited it from 27 May 2023, used it to install a web shell, and then took data from the MOVEit databases. CISA added the CVE to its Known Exploited Vulnerabilities Catalog on 2 June 2023.

## How do penetration testers detect and prove SQL injection?

Testers prove SQL injection with controlled evidence of a real effect. A database error or a 200 response is a lead, not proof.

Our method uses direct HTTP requests, controls next to every probe, and read-back where a write is involved. The evidence for a SQL injection finding has three parts.

1. A matched pair. One request where a condition is true and one where it is false, with only that condition changed. The responses must differ in the same way each time.
2. Controls. A request with a nonexistent identifier, a wrong data type, and the same probe from an unauthenticated session. If the control also changes the response, the first result was misleading and we drop or downgrade the finding.
3. A harmless read. For example, the database version string or the current database user, read once. We do not dump tables, we do not run statements that change or delete data, and we stop once the flaw is proven.

For a time-based finding we take three timing samples of the baseline and three of the delayed request, so a slow server is not mistaken for a database delay. Browser-observed checks matter where a stored value reaches a second page. Every request is saved with a timestamp.

A finding write-up names the endpoint, the parameter, the true and false requests, the controls, the database engine if proven, and the fix. It also states what was not done, such as bulk reads.

> Evidence labels used in reports: "Observed" for a reproduced result, "Browser-observed" for a result shown in a real browser, and a separate label for inferred paths. A finding that depends on an inference goes under "Limitations and open questions".
## How do you prevent SQL injection? Fixes in 7 stacks

Use parameterized queries. OWASP's cheat sheet lists them first, and says "Do not manually escape untrusted values to construct SQL queries." The query text stays a fixed string, and the driver sends values separately.

Java (JDBC):

```java
PreparedStatement ps = conn.prepareStatement(
    "SELECT id, title FROM notes WHERE owner_id = ? AND title LIKE ?");
ps.setInt(1, ownerId);
ps.setString(2, "%" + term + "%");
ResultSet rs = ps.executeQuery();
```

Python (psycopg 3). The psycopg docs say to never use `%` or `+` to merge values into queries:

```python
cur.execute(
    "SELECT id, title FROM notes WHERE owner_id = %s AND title LIKE %s",
    (owner_id, f"%{term}%"),
)
```

Node.js (node-postgres). The docs warn that concatenating parameters "can (and often does) lead to sql injection vulnerabilities":

```js
const res = await client.query(
  'SELECT id, title FROM notes WHERE owner_id = $1 AND title LIKE $2',
  [ownerId, `%${term}%`]
);
```

PHP (PDO). The PHP manual says a placeholder must stand for the whole value, so the wildcards go in the value:

```php
$stmt = $dbh->prepare("SELECT id, title FROM notes WHERE owner_id = ? AND title LIKE ?");
$stmt->execute([$ownerId, "%$term%"]);
```

C# (SqlClient), following the OWASP cheat sheet's typed parameter form:

```csharp
var cmd = new SqlCommand(
    "SELECT id, title FROM notes WHERE owner_id = @owner AND title LIKE @term", conn);
cmd.Parameters.Add(new SqlParameter("@owner", System.Data.SqlDbType.Int) { Value = ownerId });
cmd.Parameters.Add(new SqlParameter("@term", System.Data.SqlDbType.NVarChar, 200) { Value = "%" + term + "%" });
```

Go (database/sql). The Go docs say you avoid the risk "by providing SQL parameter values as sql package function arguments":

```go
rows, err := db.Query(
    "SELECT id, title FROM notes WHERE owner_id = $1 AND title LIKE $2",
    ownerID, "%"+term+"%")
```

Ruby (ActiveRecord), using a bound condition from the OWASP cheat sheet:

```ruby
Note.where("owner_id = :owner AND title LIKE :term",
           owner: owner_id, term: "%#{term}%")
```

Placeholder syntax depends on the driver: `?`, `$1`, `%s` or `@name`. Parameters protect values only. They cannot stand in for a table name, a column name or an `ORDER BY` direction. For those, map the user's choice to a fixed set in code:

```js
const SORTS = { newest: 'created_at DESC', title: 'title ASC' };
const orderBy = SORTS[req.query.sort] ?? SORTS.newest;
```

Also give the application's database account only the privileges it needs. OWASP says to "minimize the privileges assigned to every database account." That does not fix the flaw. It limits what a flaw can reach.

## Which framework defaults protect you, and where are the traps?

Query builders send values as parameters, and each framework has a raw path that does not. The Django docs say querysets are protected because "a query's SQL code is defined separately from the query's parameters," and that raw queries, `extra()` and `RawSQL` need care.

| Stack | Safe by default | Raw path that breaks it |
|---|---|---|
| Django | `filter()`, `exclude()`, `get()` | `raw()`, `extra()`, `RawSQL` with a formatted string. Pass params as a list: `raw("... %s", [name])` |
| Sequelize | Model methods with a where object | `sequelize.query()` with a string you built. Use `bind` (values travel outside the SQL text) or `replacements` (Sequelize escapes and inserts them) |
| ActiveRecord | `where(name: name)` | `where("name = '#{name}'")` with interpolation |
| Hibernate and JPA | Named parameters, Criteria | `createQuery("... " + input)` concatenation |
| PHP PDO | `prepare()` plus `execute([...])` | Building the table, column or `ORDER BY` part from input |

Sequelize shows a subtle point in its own docs. Replacements are "escaped and inserted into the query by sequelize before the query is sent to the database." Bind parameters "are sent to the database outside the SQL query text." Both are acceptable when used with values, but bind parameters match what OWASP calls true parameterization.

## Which defenses do not work alone?

Escaping, filters and a WAF reduce risk but do not remove the flaw. The table says what each one stops and what it misses.

| Defense | What it helps with | What it does not do |
|---|---|---|
| Parameterized query | Stops injection through values | Does not cover table names or `ORDER BY` |
| Allow-list mapping | Covers the parts a parameter cannot | Fails if the map accepts raw input |
| Manual escaping | Handles some known characters | OWASP lists it last and strongly discourages it |
| Input validation | Rejects bad types early | OWASP says it supplements binding and never replaces it |
| WAF | Blocks known patterns and adds logs | Misses requests outside its patterns; the bug stays in the code |
| Least privilege | Limits what a flaw can reach | Does not remove the flaw |

A WAF can buy time while you ship a fix. It is not the fix.

## SQL injection testing checklist

Use this list on every endpoint in scope, with written authorization. Record each result with a timestamp.

1. List every input that reaches a database: query string, JSON fields, form fields, headers, cookies, path segments, file names and stored profile fields.
2. Send a single quote in each input and compare to a baseline. Note errors, status changes and length changes.
3. Send a true and a false condition that differ in one token. Look for a stable response difference.
4. Add a time-delay probe where the page does not change. Take three samples against a baseline.
5. Test `ORDER BY`, `LIMIT` and column-name parameters. Parameters do not protect them, so they are common leftovers.
6. Test second-order paths: store a value, then trigger the page that reads it.
7. Run the controls: nonexistent ID, wrong type, unauthenticated request, another role.
8. Read one harmless value to prove the effect. Stop there.
9. Check that errors show no query text or stack traces to the client.
10. Check the database account's privileges from the configuration, not from an attack.

## When to get a professional SQL injection test

Scanners find the obvious cases. Second-order paths, business-specific flows and API parameters need a person who reads the app. Our [web application penetration testing](/web-application-penetration-testing/) service covers injection across forms and endpoints, and [API penetration testing](/api-penetration-testing/) covers JSON and GraphQL inputs. We agree the scope with you in writing before testing, and the report gives each finding with evidence and fix guidance.

## Questions

### What is the difference between SQL injection and XSS?

SQL injection changes a query that runs on your database, so the target is your data store. Cross-site scripting runs attacker script in a victim's browser, so the target is the user. Both are injection flaws: untrusted input is read as code by an interpreter. OWASP groups both under A05:2025 Injection.

### Does an ORM stop SQL injection?

Only when you use its query builder with values. Django querysets, for example, send parameters apart from the SQL text. Every ORM also has a raw-query path, and a string you build yourself into that path is injectable. Review each raw, literal or string-based where call.

### Can a WAF replace parameterized queries?

No. A WAF filters traffic by pattern and can miss a request the pattern does not describe. OWASP lists parameterized queries as the primary defense. Use a WAF as an extra layer and for logging, and fix the code.

### How do I prevent SQL injection in PHP?

Use PDO or mysqli with prepared statements and pass values to execute() as an array. The PHP manual states that a placeholder must stand for a whole value. Do not build table names, column names or ORDER BY clauses from input unless you map them to an allow-list.

### Is SQL injection still a problem in 2026?

Yes. OWASP Top 10:2025 lists Injection as A05, and CVE-2023-34362 in MOVEit Transfer was exploited in the wild from May 2023. New SQL injection CVEs keep appearing in NVD, mostly where code builds queries by string.

## Sources

- [OWASP SQL Injection Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html)
- [OWASP Query Parameterization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Query_Parameterization_Cheat_Sheet.html)
- [OWASP Top 10:2025 Introduction](https://top10.owasp.org/2025/0x00_2025-Introduction)
- [PortSwigger Web Security Academy: SQL injection](https://portswigger.net/web-security/sql-injection)
- [CWE-89: Improper Neutralization of Special Elements used in an SQL Command](https://cwe.mitre.org/data/definitions/89.html)
- [NVD: CVE-2023-34362 (MOVEit Transfer)](https://nvd.nist.gov/vuln/detail/CVE-2023-34362)
- [CISA advisory AA23-158A: CL0P Ransomware Gang Exploits CVE-2023-34362 MOVEit Vulnerability](https://www.cisa.gov/news-events/cybersecurity-advisories/aa23-158a)
- [NVD: CVE-2012-2574 (Symantec Web Gateway)](https://nvd.nist.gov/vuln/detail/CVE-2012-2574)
- [Django documentation: Security in Django, SQL injection protection](https://docs.djangoproject.com/en/5.2/topics/security/)
- [Sequelize v6 documentation: Raw queries](https://sequelize.org/docs/v6/core-concepts/raw-queries/)
- [node-postgres documentation: Queries](https://node-postgres.com/features/queries)
- [Psycopg 3 documentation: Passing parameters to SQL queries](https://www.psycopg.org/psycopg3/docs/basic/params.html)
- [PHP manual: Prepared statements and stored procedures](https://www.php.net/manual/en/pdo.prepared-statements.php)
- [Go documentation: Avoiding SQL injection risk](https://go.dev/doc/database/sql-injection)
