SQL Injection Prevention Guide

Parameterized queries, ORM safety patterns, and language-specific code examples to eliminate SQL injection vulnerabilities from your application.

How SQL Injection Works

SQL injection occurs when an application constructs SQL queries by concatenating user-supplied input directly into the query string. The attacker crafts input that changes the query's logical structure, allowing them to read unauthorized data, modify records, delete tables, or execute system commands. SQL injection has been the number one web application vulnerability for over two decades and remains in the OWASP Top 10.

Consider this vulnerable query: SELECT * FROM users WHERE username = '" + input + "'. If the attacker provides ' OR '1'='1 as input, the query becomes SELECT * FROM users WHERE username = '' OR '1'='1', which returns every row in the table. More destructive payloads can use UNION SELECT to extract data from other tables, ; DROP TABLE to delete data, or database-specific functions to read files and execute OS commands.

Parameterized Queries by Language

Parameterized queries are the primary defense against SQL injection. They work by sending the query structure and data values separately, so user input can never alter the SQL logic.

Node.js (mysql2):

const [rows] = await connection.execute(
  'SELECT * FROM users WHERE email = ? AND status = ?',
  [userEmail, 'active']
);

Python (psycopg2):

cursor.execute(
    "SELECT * FROM users WHERE email = %s AND status = %s",
    (user_email, "active")
)

PHP (PDO):

$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email AND status = :status');
$stmt->execute(['email' => $userEmail, 'status' => 'active']);

Java (JDBC):

PreparedStatement stmt = conn.prepareStatement(
    "SELECT * FROM users WHERE email = ? AND status = ?");
stmt.setString(1, userEmail);
stmt.setString(2, "active");
ResultSet rs = stmt.executeQuery();

ORM Safety and Pitfalls

Object-Relational Mappers use parameterized queries internally for standard operations, making them safe by default. However, every ORM provides escape hatches for raw SQL that reintroduce injection risk:

Sequelize (Node.js): Safe: User.findAll({ where: { email: input } }). Unsafe: sequelize.query("SELECT * FROM users WHERE email = '" + input + "'"). Fix: use sequelize.query("SELECT * FROM users WHERE email = ?", { replacements: [input] }).

SQLAlchemy (Python): Safe: session.query(User).filter_by(email=input). Unsafe: session.execute("SELECT * FROM users WHERE email = '" + input + "'"). Fix: use session.execute(text("SELECT * FROM users WHERE email = :email"), {"email": input}).

Eloquent (PHP): Safe: User::where('email', $input)->get(). Unsafe: DB::select("SELECT * FROM users WHERE email = '$input'"). Fix: use DB::select("SELECT * FROM users WHERE email = ?", [$input]).

Common Injection Patterns

Authentication bypass: ' OR '1'='1' -- in login forms to bypass password checks. Defense: parameterize both username and password fields, and hash passwords before comparison.

UNION-based extraction: ' UNION SELECT username, password FROM admins -- to read data from other tables. Defense: parameterized queries prevent this entirely.

Blind injection: The attacker uses conditional queries like ' AND SUBSTRING(password,1,1)='a' -- to extract data one character at a time based on true/false responses. Defense: parameterized queries and generic error messages.

Time-based blind injection: ' AND IF(1=1, SLEEP(5), 0) -- to infer information from response timing. This works even when error messages are suppressed.

Second-order injection: Malicious data is stored safely but later used in an unparameterized query. Defense: parameterize every query, including internal queries that use previously stored data.

Web Application Firewall (WAF) Rules

WAFs provide a defense-in-depth layer but should never be the sole protection. ModSecurity with the OWASP Core Rule Set (CRS) detects common injection patterns including UNION, SELECT, DROP, and comment sequences. Configure your WAF in blocking mode after testing with your application's traffic. Be aware that WAFs can be bypassed with encoding tricks, case variations, and database-specific syntax — parameterized queries remain the authoritative defense.

Least Privilege Principle

Even with parameterized queries, configure database accounts with minimal permissions. The application's database user should never have DROP, CREATE, ALTER, or GRANT privileges. Use separate accounts for read operations and write operations. Never connect to the database as root or sa. This limits the damage if an injection vulnerability is discovered in a code path you missed.

Frequently Asked Questions

What is the single most effective defense against SQL injection?
Parameterized queries (prepared statements) are the single most effective defense. They separate SQL code from data by sending the query structure and user-supplied values as distinct parameters. The database engine compiles the query structure first, then binds the data values, making it structurally impossible for user input to alter the SQL logic.
Can an ORM prevent SQL injection?
ORMs like Sequelize, SQLAlchemy, Eloquent, and Hibernate use parameterized queries internally, which prevents SQL injection for standard CRUD operations. However, ORMs are not safe when you use raw query methods, string interpolation in query builders, or user-controlled column and table names. Always use the ORM's parameterized raw query interface when you need custom SQL.
Is escaping user input a valid SQL injection defense?
Escaping is a weak defense that should never be your primary protection. Functions like mysql_real_escape_string() have known bypass techniques involving character set mismatches, numeric contexts without quotes, and second-order injection. Parameterized queries are strictly superior because they eliminate the need for escaping entirely.
What is second-order SQL injection?
Second-order SQL injection occurs when user input is stored safely in the database but later retrieved and used in a new SQL query without parameterization. For example, a username containing a SQL payload is safely inserted during registration but later concatenated into an admin query. The initial input was not malicious in its insertion context — it becomes dangerous when reused in a different query.
How do I prevent SQL injection in dynamic ORDER BY clauses?
Parameterized queries cannot be used for identifiers like column names, table names, or ORDER BY directions. For dynamic identifiers, use a whitelist approach: map user input to a predefined set of allowed values using a lookup table or switch statement. Never concatenate user input directly into identifier positions.

Written by Michael Lip — security tools at LochBot