How to prevent SQL injection and test it safely

Mon Aug 17 2026

Prove an endpoint is injectable with repeatable, database-specific behavior in an authorized test environment. So bind every value server-side, allow-list identifiers and restrict the database account.

For GET /product?id=1, the application should send a fixed query and bind 1 as data. That rule prevents the main failure with values. So this guide covers what it cannot bind and the damage controls that remain.

Payload lists are a poor starting point. Most SQL injection triage goes wrong because people classify payloads instead of evidence. So we’ll identify the observable behavior and test it against a seeded staging target. Then we’ll fix the query paths that allowed it.

SQL injection has stayed near the top of application-security warnings for a reason. OWASP ranked it first in the 2013 and 2017 Top 10; A03:2021 Injection ranked third. Invicti reports that CVE-2025-1094, a PostgreSQL SQL injection issue, appeared in an attack chain reaching U.S. Treasury infrastructure in January 2025. That attribution belongs to Invicti; it isn’t a reason to inflate this guide into a breach roundup.

In this article

SQL injection is a code-and-data boundary failure

A vulnerable endpoint might turn this request:

GET /product?id=1

into:

SELECT name, price FROM products WHERE id = '1'

If the application concatenates the input, SQL syntax from the request can change the query structure. So the database receives application instructions and untrusted text in one command.

The safe shape is:

SELECT name, price FROM products WHERE id = ?

The driver binds 1 separately. So a value such as tom' or '1'='1 remains a literal username rather than becoming a new condition. OWASP’s SQL Injection Prevention Cheat Sheet states the governing rule: prepared statements prevent an attacker from changing the intent of a query.

Keep three decisions in view. Bind values through a server-side database API. Map dynamic identifiers to fixed SQL fragments. The application needs only the permissions it uses.

If your fix is “escape the quote,” you have not fixed SQL injection; you have chosen a more fragile way to keep writing SQL.

Classify SQLi by when it runs and how you observe it

Classify the flaw along two axes: when the input becomes dangerous, and how you observe the result.

  • Classic, or in-band, SQLi returns database results or database errors in the normal HTTP response.
  • Blind SQLi hides the result, so you infer a condition from a changed page, status, response length, error, or timing.
  • Out-of-band SQLi causes the database to make an external request, commonly DNS, which you observe separately.
  • Second-order SQLi stores input during one operation and later reuses it in a vulnerable query. It describes execution timing, so it overlaps with the feedback categories above.

For /product?id=1, inspect what changes when the input changes. Does the response expose additional rows or an error? Does a true condition produce a different body from a false condition? Otherwise equivalent requests may also show a controlled delay.

A changed response is an indicator. Confirmation requires repeatability plus a database-specific or telemetry-backed explanation, ideally reproduced in a controlled lab.

That distinction matters. A scanner can notice a suspicious difference without proving that SQL syntax reached the database.

In-band SQLi gives you the answer in the normal response

Classic, or in-band, SQLi is the easiest class to prove and the easiest to over-test. Useful feedback arrives in the ordinary HTTP response, either as changed results or as a database error.

PortSwigger’s tracking-cookie example uses a response containing a “Welcome back” message to show whether a condition changed the underlying query. In your own lab, a controlled test might make an endpoint return an additional deliberately seeded synthetic row. The proof is the repeatable relationship between the input and the result, not the number of rows you can retrieve.

Error-based behavior is another response channel. An application might return:

Unterminated string literal started at position 52 in SQL SELECT * FROM tracking WHERE id = '''. Expected char

That message reveals query shape and database behavior. A deliberately seeded lab can also demonstrate conversion errors with:

CAST((SELECT example_column FROM example_table) AS int)

Do not adapt this to real credentials or production data. Keep the query inside a disposable environment containing values created specifically for the test. Some database and error configurations echo the converted value; many production configurations suppress it.

Return a generic error identifier to users. Keep detailed diagnostics in protected logs, and avoid recording sensitive request values or unnecessary query text. Hiding the error removes evidence; it does not repair the query.

Blind SQLi turns response differences into a measurement

When the response hides query results, test whether a controlled condition produces a repeatable signal. In an authorized staging environment with a deliberately seeded marker, compare database-appropriate true and false conditions. PortSwigger’s illustrative pattern is:

xyz' AND '1'='1
xyz' AND '1'='2

The exact syntax depends on the query context and database. Do not paste these into systems you do not own.

A SUBSTRING condition can test a character position, while a comparison such as > 'm' narrows a known synthetic marker with binary search. Use that only to establish control over the seeded value. Stop once the marker proves the condition reaches the database.

TechniqueSignalStop when
Boolean-basedA stable difference between true and false conditionsThe difference repeats and telemetry supports the cause
Error-basedA conditional database errorThe error correlation is established in the lab
Time-basedA controlled latency differenceTiming separates from baseline and telemetry supports it
Out-of-bandA uniquely identified external interactionThe callback correlates with the test path

Conditional errors may use a CASE expression:

CASE WHEN <condition> THEN 1/0 ELSE 'a' END

That signal disappears when the application handles every database error identically.

Timing tests need a baseline. Measure ordinary requests first, record their distribution, then use a short controlled delay and repeat the comparison minimally.

DatabaseDelay syntax
Microsoft SQL ServerWAITFOR DELAY
MySQLSLEEP()
PostgreSQLpg_sleep()

The syntax is database-specific. Network load, caching, session state, and database contention can also change latency. I can’t call a latency difference SQLi from HTTP responses alone; measure it against a baseline and correlate it with database or application telemetry.

Out-of-band application-security testing, often called OAST, helps when the response is genuinely uninformative. PortSwigger’s blind SQL injection guidance describes external interaction as a useful signal in that situation. A uniquely identified DNS callback can confirm an external interaction, but correlate it with the controlled query and application path before calling the endpoint injectable.

Test only endpoints you own, and prove less than you can

Use these probes only against systems you own or are explicitly authorized to test. Prefer a local or staging environment with synthetic records, a recent backup, rate limits, monitoring, and an explicit stopping rule.

For /product?id=1, collect several ordinary requests first. Then compare two authorized, database-appropriate test conditions against the same seeded record:

GET /product?id=<true-condition-for-the-staged-query>
GET /product?id=<false-condition-for-the-staged-query>

The placeholders above are intentional. The correct syntax depends on how the application quotes the value and which database executes it. Generate the pair in the lab, never from a generic payload list.

Compare status and body length. Check stable markers and latency too. Check database logs and correlation IDs separately. A testing caveat belongs here: cache state, sessions, retries, and backend load can produce response differences unrelated to SQL injection. Treat those differences as indicators until a controlled reproduction or telemetry-backed explanation confirms the cause.

Use tools for different jobs:

Tool or categoryBest useLimitation
Burp Suite or OWASP ZAPReplay requests and probe manuallyRequires interpretation; indicators are not proof
sqlmapTargeted confirmation in an authorized testMust be pointed at an injection point; it does not replace flow discovery or second-order analysis
Enterprise DASTRepeatable scanning in CI/CDLess flexible for custom exploitation
Lightweight scanners such as Nikto, SQLiv, or WapitiBroad reconnaissanceMay produce false positives
Template scanners such as NucleiLarge-scale discovery from templatesDiscovery does not establish exploitability

For a designated staging parameter, use conservative detection:

sqlmap -u "https://staging.example.test/product?id=1" \
  --batch --level=1 --risk=1

This deliberately avoids --dbs; proving the injection does not require enumerating the database. Do not turn a release check into a data-extraction exercise.

A scanner finding is a lead, not a breach report. Complex multi-step flows and second-order cases still need code review, integration tests, and manual tracing.

Parameterized queries should be the default everywhere

OWASP’s defense order is sound: prepared statements, safely implemented stored procedures, allow-list validation for values that cannot be bound, and escaping as a strongly discouraged fallback.

A Java endpoint can parse and bind the identifier like this:

int productId;

try {
    productId = Integer.parseInt(request.getParameter("id"));
} catch (NumberFormatException ex) {
    response.sendError(400, "Invalid product id");
    return;
}

PreparedStatement ps = connection.prepareStatement(
    "SELECT name, price FROM products WHERE id = ?"
);
ps.setInt(1, productId);

ResultSet results = ps.executeQuery();

Parsing improves type checking. Binding prevents the value from becoming SQL syntax. You need both.

PHP with PDO can use a named parameter:

$stmt = $dbh->prepare(
    "SELECT name, price FROM products WHERE id = :id"
);

$stmt->bindValue(':id', $productId, PDO::PARAM_INT);

if (!$stmt->execute()) {
    throw new RuntimeException('Database query failed');
}

$results = $stmt->fetchAll();

An ORM is not a security boundary. Its parameterized API can be safe; its raw-query escape hatch is still raw SQL. Review calls named raw, execute, fromSql, or similar, along with string interpolation inside query builders.

The same principle applies to Hibernate named parameters, ActiveRecord conditions, and equivalent APIs in C# and Rust. A method named bind proves nothing by itself. Trace one request through the driver or an integration test and determine whether the database boundary receives separate parameters or a concatenated raw query. Some libraries emulate prepared statements or offer modes that change how preparation occurs; the requirement is that user data cannot alter SQL syntax.

Placeholders bind values. They generally cannot bind a table name, column name, or sort direction. Structural variation needs an allow-list.

Dynamic SQL needs allow-lists, not clever escaping

If a report accepts name or price as a sort field, map those choices to constants:

String column;

switch (sortField) {
    case "name":
        column = "name";
        break;
    case "price":
        column = "price";
        break;
    default:
        throw new IllegalArgumentException("Unsupported sort field");
}

String direction = descending ? "DESC" : "ASC";

String sql =
    "SELECT name, price FROM products ORDER BY "
    + column + " " + direction;

Every SQL fragment comes from source code. User input selects an option; it never becomes SQL syntax.

Apply the same rule to table and column selection. OWASP warns that user-controlled targeting of table or column names signals poor design and may justify a rewrite. If users can choose arbitrary database structure, inspect that authorization and schema boundary before adding another escaping function.

Stored procedures can be as effective as prepared statements when they use fixed SQL and typed parameters. Dynamic SQL inside a procedure needs the same discipline as dynamic SQL in application code. This is the relevant call inside an existing SQL Server procedure, where @UserID and @Dept have already been supplied as procedure variables:

DECLARE @sql NVARCHAR(200);

SELECT @sql =
    N'SELECT balance FROM accounts_table
      WHERE user_ID = @UID AND department = @DPT';

EXEC sp_executesql
    @sql,
    N'@UID NVARCHAR(20), @DPT NVARCHAR(10)',
    @UID = @UserID,
    @DPT = @Dept;

The values are bound. Interpolated identifiers would still require fixed mapping.

Those procedures are not automatically safer: if the application executes them as db_owner, a successful compromise still starts with full database power. Inspect the actual execution context, procedure rights, ownership chaining, and impersonation behavior. For SQL Server, review procedures for dynamic execution patterns such as sp_execute, execute, and exec.

Use a release checklist that covers code and damage

Parameterization handles the central code-and-data failure. The release review should prove that it reaches every query path and that a separate mistake cannot hand the web process the whole database.

  1. Bind every value server-side. Trace request input through repositories and query builders. Check background jobs too. Check stored procedures too. Confirm that user data cannot alter SQL syntax.
  2. Map every structural choice. Table names, column names, and ASC or DESC must come from fixed server-side choices. Apply type, length, range, and finite-choice validation on the server.
  3. Find raw SQL escape hatches. Review ORM methods, interpolated strings, migrations, report builders, and database helper functions. Do not infer safety from a method named bind.
  4. Review stored-procedure privileges. Confirm typed parameters, safe dynamic SQL, and narrowly scoped execution rights. The production application account should not be db_owner.
  5. Limit and observe failure. Return generic external errors and protect detailed logs. Alert on repeated database errors and unusual query volume. Watch for unexpected access patterns too.
  6. Reproduce one safe proof. In staging, use a deliberately seeded marker and record the request, response, telemetry, and stopping point. Keep credentials, tokens, and production records out of the test.

If you cannot trace a value to a server-side bind and reproduce a safe staging proof, hold the release.