Last reviewed: 2026-10-16

Direct answer

Prevent SQL injection in coding-agent database patches by making SQL structure code-owned and sending every untrusted value through the database driver’s parameter interface. Do not repair string-built queries by adding quotes, escaping a few characters, or validating only the obvious HTTP field. Trace values from every external source, including model output, repository data, job payloads, imported files, and stored records, to every raw-query boundary.

Parameters represent data, not SQL grammar. Table names, column names, sort directions, operators, and fragments usually cannot occupy a value placeholder. Keep those choices fixed where possible. Where the feature genuinely needs a structural choice, map a small accepted token to a code-owned SQL fragment and reject everything else before execution. Run the application with a narrowly privileged database identity so a missed defect has a smaller blast radius.

This is a review method, not a claim that one API makes an application safe. The OWASP SQL Injection Prevention Cheat Sheet treats prepared statements with parameterized queries as the primary defense, warns that stored procedures can still construct unsafe dynamic SQL, recommends allowlists for structural parts that cannot use bind variables, and presents least privilege as defense in depth. The MITRE CWE-89 entry defines the weakness as externally influenced input changing an intended SQL command and likewise recommends parameterized execution without dynamic exec-style reconstruction.

Who this is for

This guide is for maintainers reviewing a coding agent’s changes to repositories, services, command-line tools, background jobs, data-access layers, ORMs, reporting features, or administrative scripts. It applies even when the patch never reads an HTTP request. A prompt-derived filter, issue label, CSV cell, file path, webhook field, queue message, or previously stored string can cross a trust boundary before it reaches a query.

Start with the diff, then widen the inspection to wrappers the diff calls. Pair this focused review with the site’s security scan gate , but do not let a clean scanner result replace the data-flow review. Static analysis can miss project-specific query helpers or report a safe wrapper without proving that all of its call sites follow the contract.

Key takeaways

  • Bind all data values through the selected driver’s documented parameter mechanism; never interpolate, concatenate, format, or template them into SQL text.
  • Treat query identifiers and other grammar separately from values. Prefer fixed queries; otherwise translate a closed application token through a code-owned allowlist.
  • Inspect raw SQL hidden inside ORM helpers, migration utilities, stored procedures, search builders, bulk loaders, and fallback paths.
  • Reject an invalid structural choice instead of silently substituting a default or retrying through a less strict query path.
  • Test meaning, not just errors: hostile-looking values should be stored or compared literally and must not broaden rows, add statements, or alter order rules.
  • Give each runtime the minimum database privileges it needs. Parameterization prevents a class of interpretation errors; least privilege limits damage if another path fails.
  • Preserve sanitized evidence tied to the exact patch revision, driver, database engine, test identifiers, and observed row effects.

Sources checked

  • OWASP: SQL Injection Prevention Cheat Sheet , reached on 2026-10-16, supports prepared statements with variable binding, safe stored-procedure caveats, allowlisting for identifiers and sort directions that cannot be bound, and least privilege. Its HTTP representation reported Last-Modified: 2026-10-15; that is a delivery timestamp, not proof of a new editorial event.
  • MITRE: CWE-89, Improper Neutralization of Special Elements used in an SQL Command , reached on 2026-10-16 as CWE 4.20, supports the weakness definition, consequences, prepared or parameterized queries, warnings about dynamic execution inside otherwise safe mechanisms, and minimum database privileges. The page states that it was last updated on 2026-04-30.
  • Python 3.15 sqlite3 documentation , reached on 2026-10-16, documents binding Python values with qmark or named placeholders, warns against assembling queries with string operations, and describes single-statement behavior for execute(). This is current runtime documentation, not a universal placeholder syntax for other drivers.
  • PostgreSQL 18 libpq command-execution documentation , reached on 2026-10-16, documents passing parameters separately with PQexecParams, $1-style references, the one-command restriction, prepared execution, and the separate identifier-escaping function. The page is the current supported-version reference and was live alongside the 2026-10-15 PostgreSQL 19 RC 1 announcement.

All four are primary public technical sources. They support stable security and API semantics for an evergreen review task. The review gates, example schema, evidence fields, and test matrix below are engineering recommendations derived from those documented boundaries.

Contract details to verify

Write the query contract before accepting the agent’s implementation. For each changed path, record the operation, fixed SQL shape, value parameters, allowed structural choices, database identity, maximum result or mutation scope, transaction behavior, and controlled failure. A reviewer should be able to distinguish data from grammar without reading a prompt transcript.

Contract fieldQuestionPass condition
Input originWhich values can affect the operation?Every external, model-produced, repository-derived, and stored value is classified.
SQL structureWhich tokens are code-owned?Keywords, operators, identifiers, and statement count cannot come directly from untrusted text.
Value bindingWhich driver API binds each value?The SQL text contains documented placeholders and values travel in a separate argument.
Structural choiceCan a caller choose a field or direction?A closed application token maps to one reviewed fragment; unknown tokens fail closed.
AuthorityWhich database role executes it?The role has only the required operations and data scope.
EffectsHow many rows may be read or changed?Limits and transaction expectations are explicit and asserted.
DiagnosticsWhat can be retained?Logs contain a stable query identifier and failure class, not SQL values or credentials.

Keep values out of SQL text

The database driver’s binding API is the security boundary. Python’s current sqlite3 guide says to use placeholders instead of string formatting and supports qmark and named styles. This narrow example searches a fixed column and binds the search value separately:

def find_account_by_email(connection, email):
    return connection.execute(
        "SELECT account_id, email FROM account WHERE email = ?",
        (email,),
    ).fetchone()

The trailing comma matters because (email,) is a one-item tuple. The query text stays constant whether email contains an apostrophe, SQL-looking words, comment markers, Unicode, or an empty string. Do not generalize the ? syntax to a different driver; use that driver’s documented placeholder form and parameter argument.

Binding is not the same as escaping. PostgreSQL’s libpq documentation says PQexecParams sends parameter values separately from command text and that escaping a value already passed separately is neither necessary nor correct. It also allows at most one command in that call, which is useful additional containment, but a one-command rule does not make an unsafe expression within that command acceptable.

Treat identifiers as a different interface

A value placeholder does not normally stand for a table name, column name, operator, or ASC/DESC. Do not interpolate a caller-supplied string merely because binding failed. Redesign toward a fixed query or translate an application concept to reviewed SQL:

ACCOUNT_LIST_QUERIES = {
    "created_newest": (
        "SELECT account_id, email, created_at FROM account "
        "ORDER BY created_at DESC LIMIT ?"
    ),
    "created_oldest": (
        "SELECT account_id, email, created_at FROM account "
        "ORDER BY created_at ASC LIMIT ?"
    ),
    "email_az": (
        "SELECT account_id, email, created_at FROM account "
        "ORDER BY email ASC LIMIT ?"
    ),
}

def list_accounts(connection, order_key, limit):
    query = ACCOUNT_LIST_QUERIES.get(order_key)
    if query is None:
        raise ValueError("order_key_invalid")
    if type(limit) is not int or not 1 <= limit <= 100:
        raise ValueError("limit_invalid")
    return connection.execute(query, (limit,)).fetchall()

Only complete statements defined in the module can be selected; the row limit remains a value parameter. Mapping whole statements makes the structural boundary especially easy to review. A larger application may map to reviewed fragments instead, but it must never quote arbitrary caller text. PostgreSQL documents PQescapeIdentifier for cases that genuinely require an identifier, but correct identifier escaping still does not authorize which object a caller may select. Establish authorization and scope before any engine-specific quoting.

Review abstractions and fallback paths

An ORM is not proof of parameterization. Inspect raw-query escape hatches, expression builders, order and projection helpers, manually written joins, stored-procedure bodies, migration data steps, and database-specific branches. Ask the driver to expose sanitized statement structure in a disposable test if it can do so without rendering bound values into logs.

Search the patch and its reachable wrappers for concatenation, interpolation, template engines, string-format functions, exec-like database calls, and helpers named raw, literal, fragment, or unsafe. Then determine whether each string is a fixed code constant or can contain external bytes. Do not approve an opaque helper because its name includes safe.

Keep this review distinct from schema-change risk. The site’s database migration review covers data preservation, locks, compatibility, and recovery. A migration can be operationally safe yet contain an injectable data backfill, or parameterized yet destructive by design; both contracts must pass.

Happy-path operator workflow

  1. Freeze the patch revision and inventory every changed call that can reach a database, including indirect repository helpers and error fallbacks.
  2. Mark each variable at the query boundary as a data value, a structural choice, or an internal constant. If its origin is unclear, treat it as untrusted until traced.
  3. Replace string-built values with the driver’s documented binding API. Keep the exact placeholder dialect local to that driver.
  4. Remove unnecessary dynamic structure. For required choices, create a closed mapping from application tokens to complete code-owned fragments and reject unknown keys.
  5. Run the path under the intended least-privileged test role against a disposable database using the same engine family and relevant driver version.
  6. Exercise normal values, apostrophes, Unicode, empty values, long allowed values, SQL-looking strings, invalid structural keys, and boundary limits. Assert exact returned or changed rows.
  7. Confirm that an invalid request causes no statement execution or partial mutation and that transaction cleanup matches the application contract.
  8. Review sanitized query telemetry, final diff, dependency lock, test results, and effective database grants together. Bind the evidence to the commit under review.

A useful pass record contains revision, query_id, driver, engine, database_role_class, test_case_ids, statements_observed, rows_read, rows_changed, transaction_result, and decision. It must omit connection strings, credentials, raw parameter values, production row content, and full rendered queries containing data.

Error-path operator workflow

If the review finds a query such as "... WHERE email = '" + email + "'", stop the merge path. Preserve the exact revision and location, but do not paste real input or database output into a ticket. Classify the issue as a value/structure separation failure and inspect sibling paths before assuming it is isolated.

Repair the query through the driver’s parameter API, recreate the disposable database baseline, and rerun the complete matrix. Do not add a quote-escaping helper, blacklist SQL keywords, delete comment characters, or retry the failed operation through an older raw-query path. Those approaches keep data inside command text and are sensitive to dialect, encoding, and context.

If a structural option is the problem, replace it with a fixed query or closed mapping. If product requirements truly demand open-ended identifiers, escalate for a database-specific design that combines authorization, strict naming rules, correct identifier handling, and limited privileges. Keep the patch blocked when the team cannot state which database objects the caller is allowed to select.

On any unexpected mutation, discard or restore the disposable database instead of blindly rerunning. For a possible production exposure, follow the repository’s incident process: preserve minimal evidence, rotate affected credentials where appropriate, investigate query and audit records under authorized access, and avoid testing attack strings against live data.

Prove the boundary with adversarial tests

Use synthetic records and a disposable database. The goal is not to demonstrate a clever payload; it is to prove that values remain values and structural choices remain within the written contract.

CaseRequired observation
Ordinary value and value containing an apostropheExact intended row behavior; identical statement structure.
SQL-looking value with quotes, comment markers, or boolean wordsTreated literally; no broadened result and no added statement.
Empty, null where allowed, Unicode, and maximum-length valueDocumented validation and binding behavior; no string-built fallback.
Unknown sort or field tokenRejected before database execution.
Every allowed structural tokenMaps to exactly one reviewed constant and returns the expected order or projection.
Invalid numeric limit, including a boolean in PythonRejected or bound under the explicit numeric contract; never inserted as text.
Driver error or canceled transactionOne controlled terminal result; no retry through raw SQL and no partial commit.
Low-privilege role attempts an out-of-scope operationDatabase denies it and the application fails closed without privilege escalation.
ORM or helper fallback is activatedIt preserves binding and the same structural allowlist.

Prefer assertions on returned identifiers, row counts, transaction state, and recorded query identifiers. Do not declare success merely because the database raised a syntax error: an injectable query can reject one test string while remaining exploitable through another context. Property-based generation can extend the named fixtures after the oracle is clear; the site’s property-based testing guide explains how to retain a minimal reproducible failure.

Failure modes

The patch parameterizes WHERE values but interpolates ORDER BY. Query grammar remains attacker-influenced. Map a closed sort token to a code-owned fragment or provide separate fixed queries.

Input validation is treated as quoting. A valid email, surname, search phrase, or imported label can legitimately contain punctuation. Validate business shape, then bind the resulting value; do not make a character filter carry the SQL boundary.

A stored procedure is assumed safe by location. OWASP and MITRE both warn that dynamic construction inside a procedure can reintroduce injection. Review the procedure body and its execution privileges.

An ORM method is presumed safe by default. High-level filters may bind values while raw fragments, literals, custom expressions, or identifier helpers do not. Verify the exact API reached by the patch.

Escaping is applied before binding. The database then receives altered data, and the team may later remove binding because results look wrong. Pass the original validated value once through the parameter interface.

The test asserts only that multiple statements did not run. Injection can change predicates, expressions, functions, or ordering within one statement. Assert the intended rows and effects.

A production administrator account makes tests convenient. Excess authority turns one missed path into broader disclosure or mutation. Use task-specific roles in tests and deployment.

Logs render a query with all values. That can leak credentials and personal or repository-derived data. Record a code-owned query identifier, parameter count and types where safe, timing, row counts, and stable errors instead.

The fallback path concatenates after a binding error. A controlled type or driver failure becomes a security bypass. Fail closed and repair the contract; never downgrade the execution method.

FAQ

Are prepared statements and parameterized queries always the same thing?

The terms overlap in common security guidance, but driver and database mechanics differ. Some APIs prepare once for repeated execution; others send one parameterized command. The required property is that the selected API sends values separately from command structure according to its documentation. Verify that property for the exact driver and method.

Can a placeholder bind a table or column name?

Usually no. Placeholders represent data values in expressions, not SQL grammar. Prefer a fixed schema and query. If a feature needs a choice, map a small application token to a reviewed identifier or whole fragment; do not pass caller text through a value placeholder and then fall back to interpolation.

Is allowlist validation enough for values?

No. Business validation and parameterization solve different problems. Validation decides whether a value belongs to the application contract. Binding preserves its role as data when SQL is parsed. Use both where appropriate, but do not insert a validated value into query text.

Does one-statement execution eliminate SQL injection?

No. Python documents single-statement behavior for execute(), and PostgreSQL documents the one-command limit for PQexecParams; both are useful containment. An attacker-influenced predicate or expression can still disclose or modify data within one command if structure and data were combined.

Should tests use famous injection strings?

Named SQL-looking fixtures are useful, but no payload list proves the absence of injection. The stronger proof is architectural: stable statement structure, separately bound values, closed structural mappings, exact row-effect assertions, and no unsafe fallback. Add generated cases only around that oracle.

Reader next step

Choose one database-touching coding-agent patch and create a query inventory before approving it. For every operation, label each variable as value, structural choice, or internal constant; name the driver API that binds values; and write the allowed mapping for any structural choice. Run the adversarial matrix against a disposable same-engine database with the intended low-privilege role, attach sanitized evidence to the exact revision, and keep the patch blocked until no external byte can become SQL grammar.