On this page
SQL injection happens when application data becomes executable query structure. An attacker does not need to control an entire statement: a search term, sort option, or stored profile value can be enough when code concatenates it into SQL. Parameterized queries address the central failure by keeping statement structure separate from supplied values.
Trace untrusted data to the database boundary
Start with every place data enters the service: URL parameters, JSON bodies, cookies, headers, uploaded records, and messages from other systems. Values loaded from your own database can also be untrusted if a user originally supplied them. Storage does not transform attacker-controlled text into safe SQL.
Use the JSON Formatter to inspect synthetic request examples and identify fields that eventually influence queries. For a search endpoint, mark the search text, pagination cursor, page size, sort key, and tenant context. Do not paste production records or credentials into debugging tools.
The important distinction is data versus structure. A customer's name is data. A column name, SQL operator, or ordering direction is query structure. These categories require different defenses, and applying the same string-cleaning function to both usually creates gaps.
Follow the OWASP SQL Injection Prevention Cheat Sheet for the core defense model. Input validation helps enforce business rules, but it does not replace binding parameters. A perfectly valid name containing an apostrophe should work without becoming a special security case.
Bind values instead of assembling SQL strings
A vulnerable implementation inserts user input into SQL text before the database parses it. Replacing quote characters by hand is fragile because escaping rules depend on the engine, encoding, connection settings, and surrounding syntax. Parameterized queries let the database driver handle value representation through a defined interface.
With node-postgres, the SQL uses numbered placeholders and a separate values array:
import type { Pool } from "pg";
export async function findCustomer(
pool: Pool,
tenantId: string,
email: string,
) {
const result = await pool.query(
"SELECT id, display_name FROM customers " +
"WHERE tenant_id = $1 AND email = $2",
[tenantId, email],
);
return result.rows;
}The concatenation above joins two developer-owned constant strings, not request data. The supplied email remains a value even if it contains quotes or SQL-looking text. The node-postgres query documentation explains this API and its parameter conversion behavior.
Placeholder syntax is driver-specific. PostgreSQL clients commonly use numbered placeholders, while other drivers use question marks or named parameters. Copying placeholders between drivers without checking their API can produce errors or a false sense of safety.
Parameterization does not require giving every query a named prepared statement. The security property is the separation of values from statement text through the driver's supported binding mechanism. Naming prepared statements is a separate feature with lifecycle and performance considerations.
Allowlist dynamic identifiers and ordering
Ordinary value placeholders cannot stand in for table names, column names, keywords, or ordering directions. Binding the string containing a column name usually supplies a string value, not an identifier. Developers often encounter this limitation when implementing sortable tables.
Use an allowlist mapping public options to fixed SQL fragments:
const sortColumns = {
newest: "created_at",
name: "display_name",
} as const;
type SortKey = keyof typeof sortColumns;
function isSortKey(value: string): value is SortKey {
return Object.prototype.hasOwnProperty.call(sortColumns, value);
}
export function customerQuery(sort: string) {
if (!isSortKey(sort)) throw new Error("Invalid sort option");
const column = sortColumns[sort];
return "SELECT id, display_name FROM customers " +
"WHERE tenant_id = $1 ORDER BY " + column + " ASC, id ASC LIMIT $2";
}Here the interpolated structure comes only from developer-defined constants. Bind the tenant and validated limit separately when executing this query. Obtain tenant context from authenticated server state, and clamp page size to an application-defined bound.
Do not accept an arbitrary column name merely because it contains only letters. A syntactically valid identifier may expose a sensitive column or permit an expensive sort. The allowlist is both a security boundary and a product contract.
For truly dynamic database administration features, use the database library's dedicated identifier-quoting mechanism and a separate authorization model. Such features are unusual application requirements, not a reason to accept unrestricted SQL fragments from ordinary API callers.
Handle search, lists, and ORM escape hatches
A bound LIKE pattern cannot inject SQL, but its wildcard characters still affect search semantics. If users should search literal text, escape wildcard characters according to the database's LIKE rules and specify the escape convention explicitly. If wildcards are intentional, document their meaning and bound expensive searches.
For a list of identifiers, use your driver's supported array or placeholder-expansion mechanism. PostgreSQL can use a bound array with an expression such as ANY, with the appropriate database type cast. Do not join user-supplied values into a comma-separated SQL string. Define how empty lists behave before generating the query.
ORM query builders normally bind ordinary values, but raw SQL escape hatches deserve separate review. A safe tagged-template API can bind interpolated values; a raw-string method may execute exactly what you assembled. Similar method names do not guarantee similar security properties.
The Prisma raw-query documentation illustrates this distinction. Read the documentation for your installed library rather than assuming every template literal is safe. Passing a prebuilt untrusted string into a tagged API does not magically recover a trustworthy query structure.
Keep related controls separate. The XSS prevention guide addresses browser output handling; HTML encoding does not protect SQL statements. Each interpreter needs its own boundary-aware defense.
Test security boundaries and limit database privileges
Add integration tests that submit apostrophes, Unicode, SQL-looking strings, empty arrays, invalid sort keys, and oversized page sizes. Verify exact returned records and database state, not just the HTTP status. A response that returns every tenant's records can still report success.
Test second-order injection by saving suspicious text through one endpoint and later using it through reporting or administrative features. Every execution boundary must bind values, even when the input came from a trusted-looking internal record.
Use a database account with least privilege. An ordinary application role should not be a database administrator or possess unnecessary schema-changing permissions. Privilege boundaries reduce consequences but do not excuse an injectable query.
Keep detailed database errors in restricted telemetry and return stable, non-sensitive API errors. Avoid logging full parameter arrays because they may contain personal information or secrets. Review database access changes alongside other backend API practices, including authorization and resource limits.
Finally, search the codebase for raw execution methods and SQL concatenation during review. Treat findings as investigation targets rather than proof of vulnerability: constant fragments can be safe, while a convenient helper may hide unsafe interpolation.
Key takeaways
- Bind untrusted values through the database driver's parameter API.
- Allowlist dynamic structure instead of binding identifiers as values.
- Review raw ORM methods, list handling, and search semantics explicitly.
- Test stored inputs and cross-tenant behavior, not only obvious payloads.
- Combine query safety with least privilege and sensitive-data redaction.
Reach designers & developers at work
Put your product next to the tools people use every day. One sponsor per placement, no tracking pixels.
Become a sponsor