SQL Injection
SQL injection (SQLi) is one of the most common and most devastating web application vulnerabilities. It occurs when user-supplied input is embedded directly into a SQL query without proper sanitization, allowing attackers to manipulate the query's logic. A successful SQL injection can expose your entire database, bypass authentication, modify data, or even execute OS commands.
Classic Login Bypass
Consider a login form that builds its query by string concatenation:
// VULNERABLE Node.js code — NEVER do this const query = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";
If a user enters the username admin' -- and any password, the resulting query becomes:
-- What MySQL actually executes: SELECT * FROM users WHERE username = 'admin' --' AND password = 'anything' -- The -- comments out the password check entirely
UNION-Based Data Extraction
An attacker appends a UNION to extract data from other tables. The attacker first determines the column count by injecting ORDER BY n with increasing n until an error appears, then extracts data:
-- Original query the app builds:
SELECT name, price FROM products WHERE category_id = 3
-- Step 1: attacker finds column count — 2 columns
-- Inject: 3 ORDER BY 1 -- (no error), ORDER BY 3 -- (error) => 2 columns
-- Step 2: extract database version and user
-- Inject: 3 UNION SELECT version(), user() --
SELECT name, price FROM products WHERE category_id = 3
UNION
SELECT version(), user() --
-- Returns: ("8.0.32", "webapp@10.0.0.5") mixed into product list
-- Step 3: enumerate tables
-- Inject: 3 UNION SELECT table_name, table_schema FROM information_schema.tables LIMIT 1 --
-- Step 4: extract sensitive data
-- Inject: 3 UNION SELECT username, password FROM users --Second-Order Injection
Second-order injection is trickier — the malicious payload is stored in the database first, then retrieved and injected into a query later. The input may pass initial validation but cause harm when reused.
-- Step 1: attacker registers with username: admin'--
INSERT INTO users (username, password)
VALUES ("admin'--", "attacker_pass");
-- The INSERT itself is safe (parameterized)
-- Step 2: later, the app retrieves the username and concatenates it
-- into a password-change query WITHOUT parameterization:
SET @username = "admin'--"; -- retrieved from DB
-- Vulnerable query built by app:
-- UPDATE users SET password = 'new' WHERE username = 'admin'--' AND old_pass = '...'
-- The -- comments out the old_pass check — attacker changes admin's passwordBlind Boolean-Based Injection
When the application does not display query results, attackers use blind injection to infer data one character at a time based on whether the page behaves differently for true vs false conditions:
-- If the page loads normally, condition is TRUE -- If the page errors or returns empty, condition is FALSE -- Is the first character of the admin password 'a'? (page loads = yes) 1 AND SUBSTRING((SELECT password FROM users WHERE username='admin'),1,1) = 'a' -- Repeat for each character position to extract the full password 1 AND SUBSTRING((SELECT password FROM users WHERE username='admin'),2,1) = 'd' -- After ~300 requests, full password is known
Time-Based Blind Injection
When the page looks identical for true and false responses, attackers use time delays to distinguish them:
-- If MySQL sleeps 5 seconds, the database is MySQL (not PostgreSQL/MSSQL) 1 AND SLEEP(5) -- Extract password using time: delays = '1', no delay = not '1' 1 AND IF( SUBSTRING((SELECT password FROM users LIMIT 1),1,1) = 'a', SLEEP(5), 0 ) -- Each true condition takes 5 extra seconds — attacker monitors response time
Why Escaping is Not Enough
Escaping only handles string context — numbers, identifiers (column/table names), and LIMIT values have no quoting to escape.
Character set tricks: on some older MySQL versions, multi-byte characters can swallow an escape backslash.
Developers miss one spot — a single unescaped input is enough.
Business logic changes re-expose previously safe code when queries are refactored.
mysql_real_escape_string() is context-dependent — safe for string values, useless for numeric or identifier contexts.
The Fix: Prepared Statements in All Major Languages
Prepared statements separate SQL structure from data. The query is compiled once; user input is then bound as typed parameters — it can never be interpreted as SQL syntax.
Node.js with mysql2:
// SAFE — mysql2 prepared statement
const mysql = require('mysql2/promise');
const conn = await mysql.createConnection(config);
// The ? placeholders are never interpreted as SQL
const [rows] = await conn.execute(
'SELECT id, name, email FROM users WHERE username = ? AND active = ?',
[username, 1]
);
// Vulnerable equivalent (NEVER do):
// await conn.query(`SELECT * FROM users WHERE username = '${username}'`);Python with mysql-connector-python:
# SAFE Python parameterized query
import mysql.connector
conn = mysql.connector.connect(**config)
cursor = conn.cursor(dictionary=True)
cursor.execute(
"SELECT id, name FROM users WHERE username = %s AND password_hash = %s",
(username, hashed_password) # tuple of parameters
)
user = cursor.fetchone()PHP with PDO:
// SAFE PHP PDO — named placeholders
$stmt = $pdo->prepare(
'SELECT * FROM users WHERE username = :username AND password_hash = :pw'
);
$stmt->execute([
':username' => $username,
':pw' => $hashedPassword,
]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);Java with JDBC:
// SAFE Java PreparedStatement
String sql = "SELECT * FROM users WHERE username = ? AND password_hash = ?";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, username);
stmt.setString(2, hashedPassword);
ResultSet rs = stmt.executeQuery();
if (rs.next()) { /* user found */ }
}Go with database/sql:
// SAFE Go parameterized query
rows, err := db.QueryContext(ctx,
"SELECT id, name FROM users WHERE username = ? AND active = ?",
username, 1,
)
if err != nil { log.Fatal(err) }
defer rows.Close()ORM Protection
Popular ORMs automatically parameterize queries, providing injection protection by default — but only when you use their query-building APIs, not raw string interpolation:
// Sequelize (Node.js) — SAFE: uses parameterized query internally
const user = await User.findOne({
where: { username: username, active: true }
});
// Sequelize raw query — STILL SAFE if you use replacements
const [results] = await sequelize.query(
'SELECT * FROM users WHERE username = :name',
{ replacements: { name: username }, type: QueryTypes.SELECT }
);
// Sequelize raw query — DANGEROUS if you interpolate directly
const [results] = await sequelize.query(
`SELECT * FROM users WHERE username = '${username}'` // UNSAFE
);
// Prisma (Node.js) — SAFE: always parameterized
const user = await prisma.user.findFirst({
where: { username: username }
});
// Prisma raw query — SAFE with tagged template literal
const user = await prisma.$queryRaw`SELECT * FROM users WHERE username = ${username}`;Stored Procedures — Still Need Parameterized Calls
-- SAFE stored procedure — parameter is bound as data
DELIMITER //
CREATE PROCEDURE GetUser(IN p_username VARCHAR(100))
BEGIN
SELECT id, username, email
FROM users
WHERE username = p_username; -- p_username is data, not SQL
END//
DELIMITER ;
-- DANGEROUS stored procedure — uses CONCAT + PREPARE (dynamic SQL)
DELIMITER //
CREATE PROCEDURE SortUsers(IN p_col VARCHAR(50))
BEGIN
SET @sql = CONCAT('SELECT * FROM users ORDER BY ', p_col); -- injectable!
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END//
DELIMITER ;MySQL-Specific Privilege Hardening
-- Create a restricted application user — only the minimum needed CREATE USER 'webapp'@'10.0.0.%' IDENTIFIED BY 'V3ry$tr0ng!Pass'; -- Grant only DML on the app schema GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO 'webapp'@'10.0.0.%'; -- Explicitly ensure dangerous privileges are NOT granted: -- FILE: allows LOAD DATA INFILE to read OS files -- SUPER: bypasses read-only restrictions -- DROP, CREATE, ALTER: allows schema destruction -- PROCESS: lets user see all queries in SHOW PROCESSLIST -- GRANT OPTION: lets user grant their own privileges to others -- Verify what the app user can do SHOW GRANTS FOR 'webapp'@'10.0.0.%'; -- If injection occurs with minimal privileges: -- SELECT — attacker can only read your own app data (serious but limited) -- Without FILE — cannot read /etc/passwd via LOAD DATA -- Without DROP — cannot destroy tables
WAF as Defense-in-Depth
A Web Application Firewall (WAF) can detect and block common SQL injection patterns at the network layer — but it is a supplement, not a substitute for parameterized queries. Reasons:
WAF rules can be bypassed with obfuscation (encoding, case variation, comments within keywords).
A WAF protects against known patterns — novel injection techniques may bypass it.
Parameterized queries provide mathematical proof of safety regardless of input content.
Use WAF + prepared statements + least privilege as layered defenses.
Secure Login Form Implementation
// Complete secure login example (Node.js + mysql2 + bcrypt)
const bcrypt = require('bcrypt');
const mysql = require('mysql2/promise');
async function login(username, plainPassword) {
// 1. Validate input types and lengths before touching the DB
if (typeof username !== 'string' || username.length > 100) {
throw new Error('Invalid username');
}
// 2. Parameterized query — username cannot alter SQL structure
const [rows] = await db.execute(
'SELECT id, username, password_hash FROM users WHERE username = ? AND active = 1',
[username]
);
// 3. Always perform the hash comparison even on not-found (prevents timing attacks)
const dummyHash = '$2b$12$invalidhashfortimingprotection000000000000000000000';
const storedHash = rows[0]?.password_hash ?? dummyHash;
const isValid = await bcrypt.compare(plainPassword, storedHash);
if (!rows[0] || !isValid) {
throw new Error('Invalid credentials'); // same error for both cases
}
return rows[0];
}Testing for Injection (sqlmap Awareness)
sqlmap is an open-source penetration testing tool that automatically detects and exploits SQL injection. Security teams use it to audit their own applications:
# Basic sqlmap scan of a URL parameter sqlmap -u "https://example.com/products?id=1" --dbs # Test a POST login form sqlmap -u "https://example.com/login" --data "username=admin&password=test" --dbs # If sqlmap finds injection: fix it with prepared statements, not by tweaking sqlmap rules
Quick Reference: Safe vs Unsafe Patterns
Pattern | Safe? | Reason |
|---|---|---|
"SELECT * FROM users WHERE id = " + userId | NO | String concatenation — injectable |
conn.execute("SELECT * FROM users WHERE id = ?", [userId]) | YES | Parameterized query |
"SELECT * FROM users WHERE id = " + parseInt(userId) | Marginal | Only safe if parseInt truly rejects all non-numeric input |
mysql_real_escape_string(input) | MOSTLY NO | Bypassed by charset tricks and numeric contexts |
CALL GetUser(?) | YES (if SP is safe inside) | Parameterized call to non-dynamic stored procedure |
sequelize.query( | NO | String interpolation in raw ORM query |
User.findOne({ where: { username } }) | YES | ORM parameterizes automatically |
prisma.$queryRaw | YES | Tagged template literal is parameterized |
Input Validation as Defense-in-Depth
Input validation is a defense-in-depth layer — not a substitute for parameterized queries. Validate:
Type: if you expect a number, reject anything that is not a valid integer or decimal.
Range: if you expect an ID, reject values outside 1 to 2147483647.
Allowlist for identifiers: if user input must be used as a column name, check it against a hardcoded list of valid column names.
Length: impose reasonable maximum lengths on all string inputs.
// Allowlist approach for dynamic ORDER BY columns (Node.js)
const ALLOWED_COLUMNS = ['name', 'price', 'created_at', 'rating'];
const ALLOWED_DIRS = ['ASC', 'DESC'];
function buildOrderQuery(column, direction) {
if (!ALLOWED_COLUMNS.includes(column)) {
throw new Error('Invalid sort column');
}
const dir = ALLOWED_DIRS.includes(direction?.toUpperCase()) ? direction : 'ASC';
// Safe because column came from an allowlist, not raw user input
return `SELECT * FROM products ORDER BY ${column} ${dir}`;
}Error Message Leakage Prevention
Raw MySQL error messages reveal table names, column names, and query structure — extremely useful to an attacker performing blind injection. Never expose these to end users:
// Bad: exposes MySQL error details to API consumer
app.get('/products', async (req, res) => {
const result = await db.query(`SELECT * FROM products WHERE id = ${req.query.id}`);
res.json(result);
// On error: { message: "Unknown column 'injection' in 'where clause'" }
// This tells the attacker the column names and query structure!
});
// Good: log internally, return generic error to client
app.get('/products', async (req, res) => {
try {
const [rows] = await db.execute('SELECT * FROM products WHERE id = ?', [req.query.id]);
res.json(rows);
} catch (err) {
console.error('DB error:', err); // log full error server-side
res.status(500).json({ error: 'Internal server error' }); // generic to client
}
});Additional Defense Layers
Defense | Description | Effectiveness |
|---|---|---|
Prepared statements | Separate SQL structure from data — the root fix | Prevents all SQL injection |
Least privilege | App DB user has only SELECT/INSERT/UPDATE/DELETE on its own schema | Limits damage if injection occurs |
Input validation | Allowlists, type checks, length limits | Defense-in-depth, catches some attacks |
Web Application Firewall | Pattern-based blocking at network layer | Blocks known patterns, bypassable |
Error handling | Never expose raw MySQL errors to users | Prevents reconnaissance |
ORM usage | ORMs parameterize automatically by default | Protects if raw queries are avoided |
SQL audit logging | Log and alert on UNION/SLEEP/BENCHMARK in queries | Detects active exploitation |
Regular pen testing | Run sqlmap against your own endpoints quarterly | Finds missed vulnerabilities |
Second-Order Injection Prevention Checklist
Parameterize every query that touches the database — including queries that read from your own tables.
Treat data retrieved from the database with the same suspicion as direct user input.
Never build SQL by concatenating values retrieved from the database without parameterization.
Code review every dynamic SQL statement (CONCAT + PREPARE patterns) in stored procedures.
Test: can you register a username like admin'-- and then trigger a query that uses it?
SQL Injection Quick Reference
Attack Type | Technique | Prevention |
|---|---|---|
Classic login bypass | ' OR '1'='1 | Prepared statements |
UNION extraction | UNION SELECT username, password FROM users -- | Prepared statements + least privilege |
Boolean blind | AND SUBSTRING(password,1,1)='a' | Prepared statements |
Time-based blind | AND IF(condition,SLEEP(5),0) | Prepared statements + firewall rules |
Second-order | Store payload, retrieve later into dynamic query | Parameterize ALL queries, not just initial input |
Dynamic SP injection | CONCAT('SELECT * FROM users ORDER BY ', col) | Allowlist for identifiers in dynamic SQL |
Error leakage | MySQL errors reveal table/column names | Generic error messages to client, full log server-side |
Parameterized Query Placeholder Syntax by Language
Language / Driver | Placeholder Syntax | Example |
|---|---|---|
Node.js mysql2 | ? | execute('SELECT * FROM t WHERE id = ?', [id]) |
Python mysql-connector | %s | execute("SELECT * FROM t WHERE id = %s", (id,)) |
PHP PDO (positional) | ? | prepare('SELECT * WHERE id = ?'); execute([$id]) |
PHP PDO (named) | :name | prepare('SELECT * WHERE id = :id'); execute([':id'=>$id]) |
Java JDBC | ? | prepareStatement("SELECT * WHERE id = ?"); setInt(1,id) |
Go database/sql | ? | db.Query("SELECT * WHERE id = ?", id) |
C# ADO.NET | @param | new SqlParameter("@id", id) |