MySQLPreventing SQL Injection

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:

Bash
// 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:

SQL
-- What MySQL actually executes:
SELECT * FROM users
WHERE username = 'admin' --' AND password = 'anything'
-- The -- comments out the password check entirely
Warning
String concatenation to build SQL queries is the root cause of SQL injection. There is no safe way to do this — always use parameterized queries.
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:

SQL
-- 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.

SQL
-- 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 password
Note
Second-order injection is why parameterizing the original INSERT is not enough. Every query that uses data from the database must also be parameterized, even if that data came from your own tables.
Blind 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:

SQL
-- 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:

SQL
-- 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
Warning
Time-based blind injection works even when the page returns identical content for all responses. SLEEP() and BENCHMARK() are the most common MySQL-specific timing functions used.
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:

Bash
// 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:

Bash
# 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:

Bash
// 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:

Bash
// 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:

Bash
// 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:

Bash
// 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

SQL
-- 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 ;
Warning
Dynamic SQL inside stored procedures (CONCAT + PREPARE) is just as vulnerable as application-level string concatenation. Use an allowlist to validate column/table names if dynamic identifiers are unavoidable.
MySQL-Specific Privilege Hardening

SQL
-- 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

Bash
// 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:

Bash
# 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
Note
Only run sqlmap against systems you own or have explicit written permission to test. Unauthorized use is illegal.
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(SELECT * WHERE name = '${name}')

NO

String interpolation in raw ORM query

User.findOne({ where: { username } })

YES

ORM parameterizes automatically

prisma.$queryRawSELECT * WHERE id = ${id}

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.

Bash
// 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:

Bash
// 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)