How can you prevent SQL injection vulnerabilities in JavaScript applications?
TL;DR
Keep untrusted values separate from SQL syntax by using parameterized queries or prepared statements on the server. Never build a query by concatenating request data. Validate input for business rules, but do not treat validation, escaping, an ORM, or a stored procedure as an automatic substitute for parameter binding. For SQL identifiers that cannot be parameterized, such as a requested sort column, map the request to a fixed allowlist.
// Unsafe: input becomes part of the SQL program.const sql = `SELECT * FROM users WHERE email = '${request.body.email}'`;// Safe shape: the driver sends the value separately from the SQL text.const result = await database.query('SELECT * FROM users WHERE email = $1', [request.body.email,]);
Keep query structure separate from values
SQL injection becomes possible when untrusted input is concatenated into query syntax; parameterization sends the command and values through separate channels.
Allow-list validation is still needed for identifiers or sort directions that cannot be bound as values, and the application database account should have least privilege.
The concrete threat
Suppose an attacker submits this email value:
' OR '1'='1
String concatenation can turn it into executable query syntax:
SELECT * FROM users WHERE email = '' OR '1'='1'
Parameterized queries make the database interpret the entire submitted string as one value, not as operators or SQL keywords.
Use parameterized queries for values
The placeholder syntax varies by database and driver ($1, ?, or named parameters), but the rule is the same:
async function findUserByEmail(database, email) {if (typeof email !== 'string' || email.length > 254) {throw new TypeError('Invalid email');}return database.query('SELECT id, email FROM users WHERE email = $1', [email,]);}
The length check enforces an application rule and can reduce abusive inputs; the bound parameter prevents the value from changing the query's structure. Both are useful, but they solve different problems.
Allowlist query structure
Most drivers bind data values, not table names, column names, or keywords. If an API allows sorting, map the public option to a known SQL fragment instead of interpolating it directly:
const SORT_COLUMNS = {created: 'created_at',name: 'display_name',};const sortColumn = SORT_COLUMNS[request.query.sort] ?? 'created_at';const result = await database.query(`SELECT id, display_name FROM users ORDER BY ${sortColumn} DESC`,);
Only developer-controlled values from the map reach the SQL text.
ORMs and stored procedures
ORM query builders commonly bind values when you use their structured APIs. They can still expose raw-query or literal-SQL escape hatches; parameterize values there too. Stored procedures are safe only when they avoid constructing dynamic SQL from untrusted strings.
Defense in depth
- Run database connections with the minimum privileges the application needs.
- Avoid exposing database errors or query text to clients; log diagnostic details on the server with sensitive values redacted.
- Test data-access code with adversarial inputs and review every raw SQL construction point.
- Keep the driver and ORM supported and patched.
Client-side JavaScript cannot enforce this boundary because an attacker can call the server directly. The server and database access layer must apply it to every untrusted input source, including HTTP requests, messages, imported files, and previously stored data.