CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection') - JavaScript
Overview
SQL Injection in JavaScript/Node.js applications occurs when untrusted input becomes part of the text of a SQL query instead of being bound to it as a value. An attacker who controls the query's logic can read or change anything the database account can reach, and can bypass authentication by making a lookup return a row whatever credentials are supplied. Node.js database libraries (mysql, pg, better-sqlite3, etc.) all support parameterized queries as the primary defense.
Common Node.js SQL Injection Scenarios:
- Template literals building SQL queries
- String concatenation with user input
- Using string formatting with untrusted data
- Raw SQL in Sequelize, TypeORM, or Knex without proper escaping
- Dynamic ORDER BY, table names, or column names
Popular Node.js Database Libraries:
- mysql / mysql2: MySQL client
- pg (node-postgres): PostgreSQL client
- better-sqlite3: SQLite client
- Sequelize: Multi-database ORM
- TypeORM: TypeScript ORM
- Knex.js: SQL query builder
Primary Defence: Use parameterized queries with placeholders (? for mysql/mysql2/sqlite, $1, $2 for pg), ORM query methods (Sequelize, TypeORM), or query builder parameterization (Knex).
Common Vulnerable Patterns
Template Literals
// VULNERABLE - Template literals in SQL queries
const mysql = require('mysql2');
const connection = mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'app'
});
function getUser(username) {
// VULNERABLE - Template literal with user input
const query = `SELECT * FROM users WHERE username = '${username}'`;
connection.query(query, (err, results) => {
if (err) throw err;
return results[0];
});
}
// Attack: username = "admin' OR '1'='1"
// Query becomes: SELECT * FROM users WHERE username = 'admin' OR '1'='1'
// Returns first user (authentication bypass!)
Why this is vulnerable:
- The template literal embeds
usernamein the query text, so the value arrives at the database as SQL rather than as a bound parameter. - A single quote in the value closes the string literal, and everything after it is parsed as query syntax - here an
OR '1'='1'that matches every row, so the lookup returns the first user and the caller is treated as authenticated.
String Concatenation
// VULNERABLE - String concatenation in SQL
const { Pool } = require('pg');
const pool = new Pool({
user: 'dbuser',
host: 'localhost',
database: 'myapp',
password: 'password'
});
async function deleteLog(logId) {
// VULNERABLE - String concatenation
const query = "DELETE FROM logs WHERE id = " + logId;
await pool.query(query);
}
// Attack: logId = "1 OR 1=1"
// Query becomes: DELETE FROM logs WHERE id = 1 OR 1=1
// Deletes ALL logs!
Why this is vulnerable:
logIdis concatenated into the query text with nothing checking that it is a number.- The value sits in a numeric comparison with no quotes around it, so SQL operators inside it become part of the
WHEREclause -1 OR 1=1makes the condition true for every row and theDELETEempties the table.
Express with SQLite
// VULNERABLE - Express route with SQL injection
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const app = express();
const db = new sqlite3.Database('app.db');
app.get('/user/:id', (req, res) => {
const userId = req.params.id;
// VULNERABLE - User input in template literal
const query = `SELECT * FROM users WHERE id = ${userId}`;
db.all(query, (err, rows) => {
if (err) {
return res.status(500).send(err.message);
}
res.json(rows);
});
});
// Attack: /user/1 UNION SELECT password, null FROM admin_users
// Extracts admin passwords
Why this is vulnerable:
req.params.idgoes straight from the URL into the template literal that builds the query.- The value is unquoted, so an attacker can append a
UNION SELECTthat returns rows from another table - here the passwords inadmin_users, which the route then hands back in the JSON response.
Sequelize Raw Queries
// VULNERABLE - Sequelize raw SQL with string interpolation
const { Sequelize, QueryTypes } = require('sequelize');
const sequelize = new Sequelize('database', 'username', 'password', {
host: 'localhost',
dialect: 'postgres'
});
async function searchProducts(category) {
// VULNERABLE - String interpolation in raw query
const query = `SELECT * FROM products WHERE category = '${category}'`;
const results = await sequelize.query(query);
return results[0];
}
// Attack: category = "electronics' UNION SELECT username, password FROM users--"
// Exfiltrates user credentials
Why this is vulnerable:
sequelize.query()with a raw string bypasses the parameterization that Sequelize's own query methods apply.- The category is substituted into a quoted string literal, so a closing quote followed by
UNION SELECTreturns usernames and passwords fromusersinside the product results.
Knex Raw with Concatenation
// VULNERABLE - Knex raw() with string concatenation
const knex = require('knex')({
client: 'mysql',
connection: {
host: 'localhost',
user: 'root',
password: 'password',
database: 'myapp'
}
});
async function getOrders(status) {
// VULNERABLE - String concatenation in knex.raw()
const query = knex.raw("SELECT * FROM orders WHERE status = '" + status + "'");
return await query;
}
// Attack: status = "pending' OR role='admin'--"
// Bypasses authorization checks
Why this is vulnerable:
knex.raw()parameterizes only the values passed to it as bindings; a string assembled by concatenation is sent as it stands.- Closing the quote lets the attacker replace the
statustest with one of their own -pending' OR role='admin'--drops the intended filter and returns orders the caller is not entitled to.
Dynamic ORDER BY
// VULNERABLE - User-controlled ORDER BY clause
const mysql = require('mysql2/promise');
async function getUsersSorted(sortColumn, sortOrder) {
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'app'
});
// VULNERABLE - Column name and order from user input
const query = `SELECT * FROM users ORDER BY ${sortColumn} ${sortOrder}`;
const [rows] = await connection.execute(query);
return rows;
}
// Attack: sortColumn injects a subquery, and the row order leaks the answer
// "(CASE WHEN (SELECT password FROM users WHERE id=1) LIKE 'a%'
// THEN id ELSE username END)"
// Repeat one character at a time to read the column out of the ordering
Why this is vulnerable:
- An
ORDER BYcolumn cannot be a bound parameter, so the value is interpolated and becomes part of the query structure. ORDER BYaccepts an arbitrary expression, including a subquery, so an attacker does not need to break out of a string literal - there is no quote to escape.- Nothing constrains the value to a real column name.
; DROP TABLE users-- will not work through mysql2, and that proves nothing about safety. connection.execute() prepares the statement, and multipleStatements defaults to false - mysql2 only sends the MULTI_STATEMENTS capability flag when you opt in. Stacked statements are a property of the driver rather than of the vulnerability, so a payload that fails here would succeed against a driver that permits batches. The single-statement subquery above works either way, which is why it is the better test.
TypeORM Query Builder Misuse
// VULNERABLE - TypeORM with raw SQL concatenation
import { getRepository } from 'typeorm';
import { User } from './entity/User';
async function findUsers(searchTerm: string) {
const userRepository = getRepository(User);
// VULNERABLE - String interpolation in createQueryBuilder
const users = await userRepository
.createQueryBuilder('user')
.where(`user.username LIKE '%${searchTerm}%'`)
.getMany();
return users;
}
// Attack: searchTerm = "%' OR '1'='1"
// Returns all users
Why this is vulnerable:
- The condition handed to
.where()is a template literal, so the value is concatenated into the string before TypeORM sees it and no parameter is ever bound. - Ending the
LIKEpattern early with%'lets the attacker append a condition of their own -OR '1'='1'makes the search return every user rather than the matches.
Next.js API Route
// VULNERABLE - Next.js API route with SQL injection
// pages/api/products.js
import mysql from 'mysql2/promise';
export default async function handler(req, res) {
const { category } = req.query;
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'shop'
});
// VULNERABLE - Query parameter in SQL
const query = `SELECT * FROM products WHERE category = '${category}'`;
const [rows] = await connection.execute(query);
res.json(rows);
}
// Attack: /api/products?category=electronics' OR '1'='1
// Returns all products
Why this is vulnerable:
req.query.categorycomes from the request query string into the template literal that builds the SQL.execute()is called with no parameter array, so the attacker's quote closes the string literal andOR '1'='1'returns every product row.
Secure Patterns
MySQL with Placeholders
// SECURE - MySQL parameterized queries
const mysql = require('mysql2/promise');
async function getUserSecure(username) {
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'app'
});
// SECURE - Use ? placeholder for parameters
const query = 'SELECT * FROM users WHERE username = ?';
const [rows] = await connection.execute(query, [username]);
return rows[0];
}
async function getUserByIdAndRole(userId, role) {
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'app'
});
// SECURE - Multiple parameters
const query = 'SELECT * FROM users WHERE id = ? AND role = ?';
const [rows] = await connection.execute(query, [userId, role]);
return rows[0];
}
Why this works:
connection.execute()prepares the statement, so mysql2 sends the SQL structure and the values to the server separately.- Each
?is bound as a value at execution time, so nothing inusername,userIdorroleis parsed as SQL.
PostgreSQL with Parameterization
// SECURE - PostgreSQL parameterized queries
const { Pool } = require('pg');
const pool = new Pool({
user: 'dbuser',
host: 'localhost',
database: 'myapp',
password: 'password'
});
async function deleteLogSecure(logId) {
// SECURE - Use $1, $2 placeholders
const query = 'DELETE FROM logs WHERE id = $1';
await pool.query(query, [logId]);
}
async function insertUser(username, email, role) {
// SECURE - Named placeholders with $1, $2, $3
const query = `
INSERT INTO users (username, email, role)
VALUES ($1, $2, $3)
RETURNING id
`;
const result = await pool.query(query, [username, email, role]);
return result.rows[0].id;
}
Why this works:
- PostgreSQL's
$1,$2,$3syntax creates positional parameters. - The pg library binds array values to these positions, sending them as parameters that the database treats as data, preventing SQL syntax injection.
SQLite with Parameterization
// SECURE - SQLite parameterized queries
const sqlite3 = require('better-sqlite3');
const db = new sqlite3('app.db');
function updateUserRoleSecure(userId, role) {
// Validate role against allowlist
const allowedRoles = ['user', 'moderator', 'admin'];
if (!allowedRoles.includes(role)) {
throw new Error(`Invalid role. Must be one of: ${allowedRoles.join(', ')}`);
}
// SECURE - Use named parameters or ? placeholders
const stmt = db.prepare('UPDATE users SET role = ? WHERE id = ?');
stmt.run(role, userId);
}
function getUserSecure(username) {
// SECURE - Named parameters
const stmt = db.prepare('SELECT * FROM users WHERE username = @username');
return stmt.get({ username });
}
Why this works:
- better-sqlite3 supports named parameters (
@username) and positional parameters (?). - When you pass an object or array, the library binds values as parameters, ensuring they're treated as data, not SQL code.
Sequelize ORM (Recommended)
// SECURE - Sequelize ORM methods (type-safe)
const { Sequelize, DataTypes } = require('sequelize');
const sequelize = new Sequelize('database', 'username', 'password', {
host: 'localhost',
dialect: 'postgres'
});
const Product = sequelize.define('Product', {
name: DataTypes.STRING,
category: DataTypes.STRING,
price: DataTypes.DECIMAL
});
async function searchProductsSecure(category) {
// SECURE - Sequelize automatically parameterizes
const products = await Product.findAll({
where: { category }
});
return products;
}
async function getExpensiveProducts(minPrice) {
const { Op } = require('sequelize');
// SECURE - ORM operators are safe
const products = await Product.findAll({
where: {
price: {
[Op.gte]: minPrice
}
}
});
return products;
}
Why this works:
- Sequelize ORM translates object-based queries into parameterized SQL automatically.
- Operators like
Op.gte(greater than or equal) generate safe SQL with bound parameters, never concatenating user input into queries.
Sequelize Raw with Replacements
// SECURE - Sequelize raw queries with proper bindings
const { Sequelize, QueryTypes } = require('sequelize');
const sequelize = new Sequelize('database', 'username', 'password', {
host: 'localhost',
dialect: 'postgres'
});
async function searchProductsRawSecure(category) {
// SECURE - Use :param syntax with replacements
const [results] = await sequelize.query(
'SELECT * FROM products WHERE category = :category',
{
replacements: { category },
type: QueryTypes.SELECT // Older Sequelize: Sequelize.QueryTypes.SELECT
}
);
return results;
}
async function complexQuerySecure(minAmount, maxAmount) {
// SECURE - Multiple parameters
const [results] = await sequelize.query(
`SELECT * FROM orders
WHERE amount BETWEEN :minAmount AND :maxAmount
ORDER BY created_at DESC`,
{
replacements: { minAmount, maxAmount },
type: QueryTypes.SELECT // Older Sequelize: Sequelize.QueryTypes.SELECT
}
);
return results;
}
Why this works:
- The
replacementsobject maps named parameters (:minAmount,:maxAmount) to values. - Sequelize converts these to database-specific parameterized queries, ensuring values are sent as parameters, not concatenated into SQL.
Knex with Bindings
// SECURE - Knex with proper parameter bindings
const knex = require('knex')({
client: 'mysql',
connection: {
host: 'localhost',
user: 'root',
password: 'password',
database: 'myapp'
}
});
async function getOrdersSecure(status) {
// SECURE - Knex query builder automatically parameterizes
const orders = await knex('orders')
.where('status', status)
.select('*');
return orders;
}
async function getOrdersRawSecure(status) {
// SECURE - knex.raw() with ? bindings
const orders = await knex.raw(
'SELECT * FROM orders WHERE status = ?',
[status]
);
return orders[0];
}
Why this works:
- Knex's query builder automatically parameterizes all conditions.
- For raw SQL, passing an array as the second argument binds values to
?placeholders, creating parameterized queries that prevent injection.
Dynamic ORDER BY with Allowlist
// SECURE - Dynamic ORDER BY with column allowlist
const mysql = require('mysql2/promise');
async function getUsersSortedSecure(sortColumn, sortOrder) {
// SECURE - Allowlist allowed columns
const allowedColumns = ['id', 'username', 'email', 'created_at'];
const allowedOrders = ['ASC', 'DESC'];
if (!allowedColumns.includes(sortColumn)) {
throw new Error(`Invalid column. Allowed: ${allowedColumns.join(', ')}`);
}
if (!allowedOrders.includes(sortOrder.toUpperCase())) {
throw new Error(`Invalid order. Allowed: ${allowedOrders.join(', ')}`);
}
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'app'
});
// SECURE - Use validated allowlist values (can't parameterize ORDER BY)
const query = `SELECT * FROM users ORDER BY ${sortColumn} ${sortOrder.toUpperCase()}`;
const [rows] = await connection.execute(query);
return rows;
}
Why this works:
- Column and order names cannot be parameterized in SQL, so the value has to be interpolated into the query text.
- Instead of binding it, the code checks it against an allowlist and throws before the query is built if it does not match.
- Only the literal strings in
allowedColumnsandallowedOrdersever reach the SQL, so the interpolation cannot carry attacker-controlled text.
Express with Validation
// SECURE - Express with parameterized queries and validation
const express = require('express');
const mysql = require('mysql2/promise');
const app = express();
async function getDb() {
return await mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'app'
});
}
app.get('/user/:id', async (req, res) => {
const userId = parseInt(req.params.id, 10);
if (!userId || userId < 1) {
return res.status(400).json({ error: 'Invalid user ID' });
}
try {
const connection = await getDb();
// SECURE - Parameterized query
const [rows] = await connection.execute(
'SELECT id, username, email FROM users WHERE id = ?',
[userId]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: 'Database error' });
}
});
app.get('/search', async (req, res) => {
const searchTerm = req.query.q || '';
if (searchTerm.length > 50) {
return res.status(400).json({ error: 'Search term too long' });
}
try {
const connection = await getDb();
// SECURE - Parameterized LIKE query
const [rows] = await connection.execute(
'SELECT id, username FROM users WHERE username LIKE ?',
[`%${searchTerm}%`]
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: 'Database error' });
}
});
app.listen(3000);
Why this works:
execute()with?placeholders binds the user ID and the search term, so neither reaches the database as query text.parseInt()and the length check are a second layer at the application edge: they reject bad input early, but parameterization is what keeps injection impossible if one of those checks is ever dropped.- The catch block returns a generic message rather than the driver's error text, so a failed probe tells the attacker nothing about the query or the schema.
Common Pitfalls
- Calling
sequelize.query()with a template literal that interpolates most values but passes only some of them throughreplacements. Any value still interpolated directly into the template literal instead of referenced by:nameand included inreplacementsbypasses parameterization, even though the call otherwise looks like the secure pattern. - Missing a
knex.raw()call built with string concatenation because it sits next to safe Knex query-builder calls (.where()) that auto-parameterize and look similar in a diff or code review. - Using TypeORM's
createQueryBuilder().where()with a template-literal string (`user.name = '${x}'`) instead of the parameterized form (.where('user.name = :name', { name: x })). The code still uses the query builder, but the value is concatenated into the condition string before TypeORM ever treats it as a parameter. - Allowlisting a sort column on one route and reusing the same
sortColumn/sortOrderrequest values in a later endpoint that copies the query logic without re-adding the allowlist check.