01642 06 11 11 Arrange Call

SQL Injection when Pentesting Web Applications

Ethics, scope and safety

Before any testing, ensure you have written authorisation, clearly defined scope, permitted techniques, and explicit rules for data handling. State up front that you will avoid destructive actions, large data extraction, and service degradation. Agree on a proof-of-risk approach that uses tightly controlled demonstrations rather than exploit payloads or bulk queries. Establish a data minimisation plan and a rapid notification path if you encounter live personal data.

What SQL injection is and why it matters

SQL injection is an input handling flaw where untrusted data is incorporated into a SQL statement without correct separation of code and data. The consequence is that an attacker may influence data retrieval or modification, or in some environments reach the operating system through database features. The risk varies by driver, database, and deployment model, but at its core the issue arises wherever string-built queries are used in dynamic code paths.

Typical entry points

In practice you will most often find SQL injection risks in: search boxes, login or account lookup forms, filter and sort parameters, reporting pages, export endpoints, back-office admin tools, forgotten legacy routes, and API query parameters passed through to reporting layers. Pay particular attention to multi-tenant filters such as organisation_id, to flexible filter builders, and to endpoints that concatenate ORDER BY or column names from user input. But SQL injection can be anywhere, we’ve even found them in referrer headers, so keep an open mind when testing.

Testing workflow that avoids harmful payloads

Begin at the boundary of the application and enumerate all request parameters, both visible and hidden. For each request, record the HTTP method, parameter name, server-side handler if known, data type, and observed validation. Where you suspect a SQL code path, prefer safe, non-destructive signals to demonstrate risk:

Input validation differentials.

Provide benign variations in input shape and record response differences. For example, compare an all-numeric value to an alphabetic string for a parameter that should be numeric. A SQL-backed endpoint often yields a distinct error shape or timing when given an unexpected type.

Encoding and quoting behaviour.

Supply harmless characters that exercise the parser without changing semantics, and note whether the application normalises or rejects them.

Server-side error handling.

Without forcing errors, inspect any naturally occurring error pages for tell-tale metadata such as driver names or SQLSTATE codes.

Timing channels without heavy load.

Where permitted, demonstrate that a given branch can influence query planning cost in a reversible, bounded way, while keeping total request counts very low and staying under agreed latency ceilings.

Read-only proof-of-risk.

Prove that the application is treating data as code by showing that harmless input can alter the shape of a result set or a sort order in a controlled test dataset that you create with the client’s consent.

Throughout, keep requests counted and rate limited, and capture full request–response pairs with timestamps and correlation IDs.

Recognising patterns that tend to be vulnerable

During code reviews or black-box testing, watch for the following patterns, which you can document and discuss without using exploit strings:

  • Building SQL with string concatenation, for example "... WHERE id = " + userInput
  • Interpolating identifiers such as column or table names from request parameters
  • Building dynamic ORDER BY, GROUP BY, or LIMIT/OFFSET from user input
  • Hand-rolled query builders that claim to escape values but not identifiers
  • Legacy ORM queries that fall back to .raw() methods without parameters
  • Conditional fragments appended from feature flags or per-tenant filters

Safe code examples: vulnerable vs secure

The most valuable remediation advice is concrete. Below are short, language-specific examples that show the vulnerable pattern and the secure equivalent using parameters or safe whitelisting. The examples are intentionally free of payloads and use neutral inputs.

Python with psycopg2 (PostgreSQL)

Vulnerable pattern:

# Avoid: string concatenation
def get_user(conn, user_id):
    sql = f"SELECT id, email FROM users WHERE id = {user_id}"
    with conn.cursor() as cur:
        cur.execute(sql)                  # user_id is concatenated
        return cur.fetchone()

Secure pattern with parameters:

def get_user(conn, user_id):
    sql = "SELECT id, email FROM users WHERE id = %s"
    with conn.cursor() as cur:
        cur.execute(sql, (user_id,))      # parameter binding
        return cur.fetchone()

Safe whitelisting for sort keys:

ALLOWED_SORTS = {"email": "email", "created": "created_at"}
def list_users(conn, sort_key):
    column = ALLOWED_SORTS.get(sort_key, "created_at")
    sql = f"SELECT id, email FROM users ORDER BY {column} NULLS LAST"
    with conn.cursor() as cur:
        cur.execute(sql)                  # identifiers controlled by code
        return cur.fetchall()

Java with JDBC

Vulnerable:

String sql = "SELECT * FROM accounts WHERE org_id = " + orgId;
ResultSet rs = conn.createStatement().executeQuery(sql);

Secure:

String sql = "SELECT * FROM accounts WHERE org_id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
  ps.setLong(1, orgId);
  ResultSet rs = ps.executeQuery();
}

PHP with PDO

Vulnerable:

$sql = "SELECT * FROM products WHERE sku = '" . $_GET['sku'] . "'";
$rows = $pdo->query($sql)->fetchAll();

Secure:

$stmt = $pdo->prepare("SELECT * FROM products WHERE sku = :sku");
$stmt->execute([':sku' => $_GET['sku']]);
$rows = $stmt->fetchAll();

C# with ADO.NET

Vulnerable:

var sql = "SELECT * FROM orders WHERE customer_id = " + customerId;
using var cmd = new SqlCommand(sql, conn);

Secure:

const string sql = "SELECT * FROM orders WHERE customer_id = @id";
using var cmd = new SqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@id", customerId);

Node.js with pg

Vulnerable:

const sql = `SELECT * FROM logs WHERE day = '${req.query.day}'`;
const { rows } = await client.query(sql);

Secure:

const sql = "SELECT * FROM logs WHERE day = $1";
const { rows } = await client.query(sql, [req.query.day]);

Advanced scenarios and how to handle them responsibly

Dynamic reporting and analytics builders

If the application allows users to build flexible reports, isolate three planes of input: data values, identifiers, and structure. Bind values with parameters. For identifiers, apply strict whitelists and map user-facing names to internal columns. For structure, supply pre-built templates that the user toggles rather than free-form fragments. Document these controls in your report and recommend tests that verify each plane is enforced.

ORMs and micro-ORMs

Most mature ORMs default to parameterisation, but edge cases remain. Review any .raw() or string-built query methods and any points where developers bypass the ORM for performance. Ensure that query interpolation features do not accept unsanitised identifiers. Provide migration guidance toward repository patterns that centralise raw SQL and wrap it with parameter helpers.

Stored procedures and functions

Stored procedures do not inherently solve injection. If they build dynamic SQL inside the procedure using concatenation, the risk remains. Recommend parameterised EXEC equivalents and defensive coding standards inside procedures. Verify permissions so the application role cannot create or alter procedures in production.

JSON APIs and GraphQL

Injection can surface even when the client is a single-page app. Treat all server-side uses of client-supplied values the same way. For GraphQL that feeds SQL resolvers, bind and validate arguments and block unbounded filters being passed directly to SQL WHERE clauses. When resolvers accept sort keys, apply the identifier whitelist pattern.

Compensating controls

While parameterisation is the primary fix, layered controls help reduce residual risk:

  • Least-privilege database roles. The application role should have only the statements it needs. Read-only paths should not be able to write. Admin features should be separate services or roles.
  • Input validation. Validate type, length, format, and range at the boundary. Prefer allow-lists for enumerations and well-known identifiers.
  • Escaping as a last resort. Use database-specific escaping only in addition to parameters, never instead of them.
  • Web application firewalls. WAFs can block obvious attack traffic but are not a substitute for fixes. Tune them per application to reduce false positives.
  • Monitoring and logging. Log query templates with parameter metadata rather than full values, and alert on anomalies such as sudden increases in error rates or unusual query shapes.

Verification without exploitation

Proving impact responsibly is often a matter of demonstrating control over the shape of the query rather than executing harmful statements. For example, with an agreed test dataset, show that the result ordering or filtering changes only when the parameter is treated as code, then show that the same request using a parameterised endpoint preserves the expected behaviour. Capture both traces and include them in your report.

What to put in your report

Write with remediation in mind. A useful report typically includes:

  • A clear description of the affected endpoints, parameters, and code paths
  • Evidence that input is incorporated into SQL without parameter binding
  • The business impact aligned to the client’s data classification and roles
  • Specific code-level remediation, ideally with language-appropriate examples as above
  • Environmental hardening steps such as least privilege and logging improvements
  • A verification plan that the client can execute after a fix is deployed

Developer standards you can recommend

Offer a short standard the client can adopt:

  • Never build SQL by concatenating untrusted input
  • Use parameterised queries everywhere
  • Whitelist any dynamic identifiers or sort keys
  • Centralise raw SQL and wrap it with helper functions that enforce parameters
  • Enforce least-privilege roles for each application subsystem
  • Require code review checklists that include “no string-built SQL”
  • Add automated linting or static analysis rules that flag concatenated SQL

Safe lab environments for practice

If the client wants to train their team, advise them to use intentionally vulnerable training platforms in an isolated environment and to follow the platforms’ own curricula rather than inventing tests on live systems. Ensure no production data, credentials, or network paths are reachable from the lab.

Quick checklists

Triage checklist for a suspected SQL code path

  1. Is the parameter used in a SQL WHERE, ORDER BY, or LIMIT?
  2. Is binding done through parameters in the driver or ORM?
  3. Are any identifiers derived from the request and, if so, whitelisted?
  4. Does the application role have only the minimum privileges?
  5. Do logs show query templates rather than string-built queries?

Remediation checklist

  1. Replace concatenation with parameterised statements
  2. Introduce identifier whitelists for dynamic ordering and filtering
  3. Split read-only and write operations into separate roles
  4. Add input validation at the boundary
  5. Add tests that fail on concatenated SQL

Closing note

This guide is intended to help you assess and remediate SQL injection safely and lawfully. If you would like, I can adapt it into a client-facing remediation standard, add language-specific examples for your stack, or convert it into a testing checklist formatted for your team’s templates.

If you’re looking to become a pen-tester, check out our pen test jobs board for available positions, or if you’re looking to get your web app penetration tested, drop us a message.