How SQL injection happens: concatenated string versus prepared statement
Uncategorized

SQL Injection in PHP: What It Is and How to Prevent It

SQL injection is still the most damaging bug you can leave in a PHP application. It lets somebody read your entire database, log in as any user, or delete your tables — using nothing but a web form. It has been the best understood vulnerability in web development for twenty years and it is still found in production code every week, because the code that causes it looks completely ordinary.

This article shows the vulnerable pattern, what an attacker actually types, and the fix. The fix is short: use prepared statements, every time, with no exceptions.

What SQL injection is

Your application builds an SQL query as a string. Part of that string comes from the user. If the user’s input is treated as part of the query rather than as data, they can change what the query does.

How SQL injection happens: concatenated string versus prepared statement
The same input is data in a prepared statement and code in a concatenated string.

That is the whole vulnerability. Everything else is detail.

The vulnerable code

Here is a login check written the way it was taught for years, and the way it still appears in older codebases:

<?php
// VULNERABLE - do not use
$email    = $_POST['email'];
$password = $_POST['password'];

$sql = "SELECT * FROM users WHERE email = '$email' AND password = '$password'";
$result = $mysqli->query($sql);

if ($result->num_rows > 0) {
    // logged in
}

It works. It passes testing. And an attacker who types this into the email box logs in as your first user without knowing any password:

' OR '1'='1' -- 

Because the input is pasted into the string, the query the database receives becomes:

SELECT * FROM users WHERE email = '' OR '1'='1' -- ' AND password = ''

'1'='1' is always true, and -- comments out the rest of the line, so the password check disappears entirely. The database does exactly what it was asked. Nothing was hacked — the query was rewritten by the person filling in the form.

The bug is not that the input was “dangerous”. The bug is that data and code were put in the same string. Every real fix comes from separating them again.

What an attacker can do next

  • Log in as anybody, including an administrator, using the example above.
  • Read other tables with a UNION query — customer records, order history, password hashes.
  • Work out your schema one character at a time, even when the page shows no output, using timing or true/false responses (blind SQL injection).
  • Change or delete data, if the database user has permission to.

That last point matters: if your application connects as a user with DROP and ALTER rights, a single injection can destroy the database. It almost never needs those rights.

The fix: prepared statements

A prepared statement sends the query and the data to the database separately. The query structure is fixed before the data arrives, so nothing the user types can change it. There is no escaping to get right, and no edge case to forget.

With PDO

<?php
$pdo = new PDO('mysql:host=localhost;dbname=shop;charset=utf8mb4', $user, $pass, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES   => false,   // use real prepared statements
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute([$_POST['email']]);
$user = $stmt->fetch();

if ($user && password_verify($_POST['password'], $user['password_hash'])) {
    // logged in
}

Two details in that connection are worth keeping. ATTR_EMULATE_PREPARES => false makes PDO use the database’s own prepared statements instead of building the query in PHP. And charset=utf8mb4 in the DSN closes an old encoding trick that could smuggle a quote past escaping.

With MySQLi

<?php
$stmt = $mysqli->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->bind_param('s', $_POST['email']);   // s = string, i = int, d = double, b = blob
$stmt->execute();
$user = $stmt->get_result()->fetch_assoc();

Named placeholders read better once a query has several parameters, and PDO supports them:

<?php
$stmt = $pdo->prepare(
    'SELECT * FROM orders WHERE customer_id = :customer AND status = :status ORDER BY created_at DESC'
);
$stmt->execute([':customer' => $customerId, ':status' => 'paid']);

The things people use instead, and why they fail

Why escaping and blocklists fail to stop SQL injection
Escaping and blocklists both ask you to be perfect forever.

Escaping with addslashes() or real_escape_string()

Escaping can be made to work and is very easy to get wrong. Miss one variable out of forty and the application is vulnerable again. Get the connection charset wrong and escaping can be bypassed outright. Prepared statements remove the whole category; escaping asks you to be perfect forever.

Checking the input for bad words

Blocklists that look for SELECT, UNION or -- are bypassed with comments, case changes, encoding and whitespace tricks. They also break legitimate input: a customer whose company is called “Select Traders” cannot use your form.

Validation on its own

Validation is worth doing — see our guide on how to sanitize and validate user input in PHP — but it is not a defence against injection. A name field legitimately contains apostrophes. Validation checks that data is sensible; prepared statements make it impossible for data to become code.

The parts a prepared statement cannot bind

Placeholders work for values. They do not work for table names, column names, or the direction of an ORDER BY. This is invalid:

$stmt = $pdo->prepare('SELECT * FROM users ORDER BY ? ?');   // does not work

When a sort column comes from the user, check it against a list you control:

<?php
$allowedColumns = ['created_at', 'name', 'total'];
$column    = in_array($_GET['sort'] ?? '', $allowedColumns, true) ? $_GET['sort'] : 'created_at';
$direction = (($_GET['dir'] ?? '') === 'asc') ? 'ASC' : 'DESC';

$sql = "SELECT * FROM orders ORDER BY {$column} {$direction}";   // safe: both values came from our own list

The rule is that anything interpolated into SQL must come from a list in your code, never from the request. An allowlist of three columns is safe; “strip the dangerous characters from whatever they sent” is not.

Using a framework

Laravel’s query builder and Eloquent use prepared statements under the hood, so ordinary code is safe by default:

<?php
// safe - bound automatically
User::where('email', $request->email)->first();
DB::table('orders')->where('status', $request->status)->get();

// unsafe - raw SQL with a concatenated value
DB::select("SELECT * FROM users WHERE email = '" . $request->email . "'");

// safe - raw SQL with bindings
DB::select('SELECT * FROM users WHERE email = ?', [$request->email]);

The danger in a framework is always the raw escape hatch: DB::raw(), whereRaw(), selectRaw(). They are not wrong to use, but every value inside them must be a binding, never a concatenated string.

Defence in depth

  1. Give the database user only what it needs. A web application almost never needs DROP, ALTER or FILE. Restricting rights turns a catastrophic injection into a limited one.
  2. Turn off detailed SQL errors in production. Error messages that print the failing query hand an attacker your schema. Log them instead.
  3. Store passwords with password_hash(). If a database is read, hashes are far less useful than plaintext.
  4. Rate limit login and search endpoints — see rate limiting in PHP. Blind injection needs thousands of requests, and a rate limit makes it impractical.
  5. Log unusual queries. A sudden burst of failed logins or long-running queries is often the first sign.

How to check your own code today

Search the codebase for the shapes that cause this. In most projects it takes ten minutes:

Three grep commands to audit PHP code for SQL injection
Three greps that find every place a request value can reach SQL.
grep -rn "\$_GET\|\$_POST\|\$_REQUEST" --include=*.php . | grep -i "select\|insert\|update\|delete"
grep -rn "query(\|exec(" --include=*.php . | grep '\$'
grep -rn "whereRaw\|selectRaw\|DB::raw" --include=*.php .

Every result is a place where a request value may be reaching SQL. Each one is either already using bindings, or it is a bug to fix now.

Second-order SQL injection

The dangerous input does not always arrive and get used in the same request. Somebody registers with the username admin'--. Your registration form uses a prepared statement, so the row is stored safely, exactly as typed. Three screens later a reporting page reads that username out of the database and builds a query with it, because “it came from our own database, so it is safe”.

It is not safe. Data does not become trustworthy by being stored. The rule is about where a value is going, not where it came from: anything that ends up inside SQL must be bound, whether it came from a form, a database row, a JSON file or an API response.

<?php
// still vulnerable, even though $row came from our own database
$row = $pdo->query('SELECT username FROM users WHERE id = 42')->fetch();
$sql = "SELECT * FROM audit_log WHERE actor = '{$row['username']}'";   // second-order injection

// correct
$stmt = $pdo->prepare('SELECT * FROM audit_log WHERE actor = ?');
$stmt->execute([$row['username']]);

Blind SQL injection, where nothing is printed

Developers often assume an injection is harmless if the page shows no database output. It is not. When the response reveals nothing, an attacker asks yes/no questions instead and reads the database one character at a time.

Boolean-based and time-based blind SQL injection
Two ways to read a database that never prints anything.

Boolean-based

The attacker sends two inputs that differ only in a condition, and watches whether the page behaves differently — a product found or not found, a login error or a different login error. Each request answers one bit.

' AND SUBSTRING((SELECT password_hash FROM users LIMIT 1),1,1)='a'-- 

If the page behaves as it does for a valid product, the first character is a. If not, try b. It is slow for a person and instant for a script.

Time-based

When the page looks identical either way, the attacker asks the database to pause instead. A response that takes five seconds means the condition was true.

' AND IF(SUBSTRING(DATABASE(),1,1)='s', SLEEP(5), 0)-- 

Both are defeated by the same fix. A prepared statement never lets the condition become part of the query, so there is no bit to read and nothing to time.

Where frameworks still let it through

Every query builder has an escape hatch, and every escape hatch is ordinary SQL again. In Laravel the names are whereRaw, selectRaw, havingRaw, orderByRaw and DB::raw.

<?php
// unsafe - the value is concatenated into the fragment
$users = User::whereRaw("created_at > '" . $request->from . "'")->get();

// safe - the fragment is fixed, the value is bound
$users = User::whereRaw('created_at > ?', [$request->from])->get();

// unsafe - a user-supplied column inside a raw order
$orders = Order::orderByRaw($request->sort . ' desc')->get();

// safe - the column comes from a list we control
$sort = in_array($request->sort, ['created_at', 'total'], true) ? $request->sort : 'created_at';
$orders = Order::orderBy($sort, 'desc')->get();

The same applies to WordPress: $wpdb->prepare() exists for exactly this, and $wpdb->query() with a concatenated string is the same bug in a different codebase.

Checking that your fix actually works

After fixing a query, verify it rather than assuming. Three checks, in order of effort:

  1. Send the classic payloads by hand into every form field and URL parameter: ', ' OR '1'='1, '; SELECT 1--. Correct behaviour is a normal “not found” or validation message. A database error means the input is still reaching the parser.
  2. Turn on the query log in staging and confirm the query arrives with placeholders and separate parameters, not as one assembled string.
  3. Run an automated scanner against your own staging site. Tools such as sqlmap exist for this. Run them only against systems you own or have written permission to test — running them against anything else is a criminal offence in most countries, India included.

If you think it already happened

Treat a suspected injection as a data breach until proven otherwise, and work in this order:

  • Fix the hole first. Patch the query and deploy. Everything else is pointless while the door is open.
  • Rotate the database credentials, and any API keys or secrets that were readable from the database.
  • Read the access logs around the affected endpoint. Injection leaves a distinctive trail: hundreds of near-identical requests differing by one character, or long response times in a pattern.
  • Force a password reset if the users table was reachable, even if passwords were hashed.
  • Write down what you found. If personal data was involved, Indian law now has notification duties under the DPDP framework, and a written timeline is the first thing anybody will ask for.

A pattern worth adopting

Two habits remove most of this class of bug permanently, and both are cheap.

First, keep SQL out of controllers. Queries belonging to a repository or model class means there are twenty places to audit, not two hundred, and a code review can actually cover them.

Second, treat every raw SQL call as a review flag. In a project with a linter, add a rule that flags query(, exec( and the *Raw methods. The tool does not need to judge whether the call is safe — it only needs to make sure a human looked.

The two queries prepared statements make awkward

Placeholders cover almost everything. Two shapes need a little more work, and both are where developers quietly fall back to concatenation.

An IN () list of unknown length

You cannot bind an array to a single placeholder. Build the right number of placeholders instead — the count comes from your code, the values stay bound:

<?php
$ids = array_map('intval', $request->ids ?? []);      // ids are integers
if ($ids === []) { return []; }

$in   = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT * FROM products WHERE id IN ($in)");
$stmt->execute($ids);

The string that goes into the SQL is only question marks and commas, generated from a count. Nothing the user typed reaches the query text.

A search form where some filters are optional

The usual mess is a query built by concatenating whichever filters were filled in. Collect conditions and bindings side by side instead:

<?php
$where = ['status = ?'];
$bind  = ['paid'];

if ($request->filled('client')) { $where[] = 'client_id = ?'; $bind[] = $request->client; }
if ($request->filled('from'))   { $where[] = 'created_at >= ?'; $bind[] = $request->from; }

$sql  = 'SELECT * FROM orders WHERE ' . implode(' AND ', $where) . ' ORDER BY created_at DESC';
$stmt = $pdo->prepare($sql);
$stmt->execute($bind);

Every fragment in $where is written by you. Every value is in $bind. That separation is the whole discipline, and it scales to a search form with twenty filters.

Fixing an old codebase without stopping work

A legacy project with three hundred queries cannot be rewritten in an afternoon, and a rewrite that is never finished protects nobody. Work by exposure instead.

  1. Fix what is reachable without logging in first. Login forms, search boxes, product pages, contact forms, anything taking an id from the URL. This is where attacks actually land.
  2. Then anything an ordinary logged-in user can reach.
  3. Admin-only screens last. Still fix them — an admin account is exactly what an attacker wants — but they are behind a door.
  4. Add the linter rule now, so the count goes down and never back up.

If a query is genuinely too tangled to convert today, at least cast the value to the type it must be. (int) $_GET['id'] is not a substitute for a prepared statement, but an integer cast on a numeric id closes that one hole completely while you work through the rest.

Errors and logs that do not help an attacker

Two settings decide how much a failed injection attempt tells the person trying it.

<?php
// production
ini_set('display_errors', '0');
ini_set('log_errors', '1');

$pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);

try {
    $stmt->execute($bind);
} catch (PDOException $e) {
    error_log('query failed: ' . $e->getMessage());   // full detail to the log
    http_response_code(500);
    echo 'Something went wrong.';                     // nothing useful to the visitor
}

A printed SQL error hands over your table and column names, which is how a blind injection becomes a fast one. Log the detail, show a sentence. And keep the log outside the web root — an error log that can be downloaded is worse than no log at all.

Frequently asked questions

Are prepared statements slower?

No, not in any way that matters for a web application. The database parses the query once and can reuse the plan. Any difference is far smaller than a single network round trip.

Do I still need to validate input?

Yes, for different reasons: correctness, business rules, and other vulnerability classes such as XSS. Prepared statements stop injection; validation stops nonsense.

Is an ORM enough on its own?

Almost. An ORM used normally is safe. The raw query methods it provides are where injection comes back, so treat those as ordinary SQL and bind every value.

What about stored procedures?

A stored procedure is not automatically safe. If it builds dynamic SQL from its parameters and executes it, the same vulnerability exists inside the database instead of inside PHP.