Skip to content

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 username in 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:

  • logId is 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 WHERE clause - 1 OR 1=1 makes the condition true for every row and the DELETE empties 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.id goes straight from the URL into the template literal that builds the query.
  • The value is unquoted, so an attacker can append a UNION SELECT that returns rows from another table - here the passwords in admin_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 SELECT returns usernames and passwords from users inside 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 status test 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 BY column cannot be a bound parameter, so the value is interpolated and becomes part of the query structure.
  • ORDER BY accepts 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 LIKE pattern 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.category comes 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 and OR '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 in username, userId or role is 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, $3 syntax 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.
// 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 replacements object 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 allowedColumns and allowedOrders ever 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 through replacements. Any value still interpolated directly into the template literal instead of referenced by :name and included in replacements bypasses 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/sortOrder request values in a later endpoint that copies the query logic without re-adding the allowlist check.

Additional Resources