SQL Injection

The most dangerous database vulnerability and how parameterized queries prevent it

The Attack

SQL injection happens when user input is inserted directly into a SQL string, allowing an attacker to modify the query's logic. It's the single most dangerous vulnerability in web applications — and the most preventable.

Imagine you build a login form and construct the SQL query by concatenating the username:

// WRONG — vulnerable to SQL injection! const query = "SELECT * FROM users WHERE username = '" + username + "'";

If a user enters a normal username like alice, you get a valid query:

SELECT * FROM users WHERE username = 'alice'

But what if someone enters this as their "username"?

User input:   ' OR '1'='1' --

Resulting query:
SELECT * FROM users WHERE username = '' OR '1'='1' --'

What the database sees:
  username = ''      → false
  OR '1'='1'         → always true!
  --                 → comment (ignores the rest)

Result: Returns ALL users. Attacker is "logged in" as the first user.

Destructive Injection

It gets worse. An attacker could enter input designed to destroy your data entirely:

User input:   '; DROP TABLE stories; --

Resulting query:
SELECT * FROM users WHERE username = ''; DROP TABLE stories; --'

What the database executes:
  1. SELECT * FROM users WHERE username = ''   (returns nothing)
  2. DROP TABLE stories                         (deletes your entire table!)
  3. --'                                        (rest is a comment)

Your stories table is gone.

The Fix: Parameterized Queries

Parameterized queries (also called prepared statements) separate the SQL structure from the data values. The database treats parameters as data, never as SQL code — so malicious input can't change the query's logic.

WRONG vs RIGHT — Node.js

Here is a vulnerable Node.js query next to its safe equivalent:

WRONG — String Concatenation:

// NEVER do this! const query = `SELECT * FROM stories WHERE id = ${req.params.id}`; const result = await pool.query(query); // Attacker sends: id = "1; DROP TABLE stories" // Query becomes: // SELECT * FROM stories WHERE id = 1; // DROP TABLE stories

RIGHT — Parameterized Query:

// Always use parameterized queries const result = await pool.query( 'SELECT * FROM stories WHERE id = $1', [req.params.id] ); // Attacker sends: id = "1; DROP TABLE stories" // Database sees id = '1; DROP TABLE stories' // (treated as a string value, not SQL) // Result: no matching row, no damage

WRONG vs RIGHT — PHP

The same pattern applies to PHP — string concatenation is vulnerable, prepared statements are safe:

WRONG — String Concatenation:

// NEVER do this! $query = "SELECT * FROM stories WHERE id = " . $_GET['id']; $result = $pdo->query($query); // Attacker sends: ?id=1;DROP TABLE stories // Query becomes: // SELECT * FROM stories WHERE id = 1; // DROP TABLE stories

RIGHT — Prepared Statement:

// Always use prepared statements $stmt = $pdo->prepare( 'SELECT * FROM stories WHERE id = :id' ); $stmt->execute(['id' => $_GET['id']]); $result = $stmt->fetch(); // Attacker sends: ?id=1;DROP TABLE stories // Database sees id = '1;DROP TABLE stories' // (treated as a value, not SQL) // Result: no matching row, no damage

How Parameterized Queries Work Under the Hood

The key difference is that parameterized queries use a two-step process at the database protocol level:

String Concatenation (VULNERABLE):

  Your code:    "SELECT * FROM users WHERE name = '" + input + "'"
  Database sees: One big string to parse as SQL
  Problem:       Input becomes part of the SQL structure

Parameterized Query (SAFE):

  Step 1 - PREPARE:  Database parses "SELECT * FROM users WHERE name = $1"
                      SQL structure is locked in — one condition, one value
  Step 2 - EXECUTE:  Database receives $1 = "'; DROP TABLE users; --"
                      This is treated as a data value, period.
                      Cannot change the query structure.

With parameterized queries, the SQL structure is parsed and compiled before the values are provided. By the time the user data arrives, the database has already decided what the query does — the values can only fill in data slots, never alter the query logic.

Other Injection Prevention Practices

While parameterized queries are the primary defense against SQL injection, a defense-in-depth approach includes additional layers:

  • Input validation: Verify that IDs are integers, emails match expected formats, and strings don't exceed expected lengths before they reach the database layer.
  • Least privilege: The database user your application connects with should only have the permissions it needs (SELECT, INSERT, UPDATE, DELETE on specific tables) — never grant superuser access.
  • ORMs and query builders: Tools like Sequelize, Prisma, Eloquent, and Doctrine generate parameterized queries automatically, reducing the chance of manual mistakes.
  • Web Application Firewalls (WAFs): Can detect and block common injection patterns at the network level, providing an additional safety net.