Skip to main content

🛡️ Prepared Statements and Security

SQL injection has topped web-security threat lists for over two decades and has caused some of history's largest data breaches. The good news: one technique — prepared statements — shuts the door almost completely. This lesson shows you exactly how injection works, why prepared statements stop it, and how to layer on the rest of a solid defense.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain how SQL injection works and the damage it can do
  • Write prepared statements in both MySQLi and PDO to neutralize injection
  • Hash and verify passwords correctly with password_hash() and password_verify()
  • Safely handle dynamic table and column names with a whitelist
  • Build a reusable, secure data-access layer that centralizes these protections

Estimated Time: 50–65 minutes  •  Difficulty: Intermediate

Hands-on: Convert a vulnerable login query into a safe prepared statement and prove an injection payload no longer works.

In This Lesson

The Threat Landscape

Any application that mixes user input with a database faces a family of risks. The big ones:

  • SQL injection — malicious SQL smuggled in through user input
  • Data exposure — leaking sensitive data through errors or over-broad queries
  • Weak access control — over-privileged database accounts
  • Insecure credential storage — plaintext or weakly hashed passwords
💡 The bank-vault analogy. Your database is a vault of valuables. Connection credentials are the keys to the building, and user privileges are the permissions to open specific safety-deposit boxes. SQL injection is a thief tricking the guard into opening doors they never should. A prepared statement is a strict protocol the guard follows that simply can't be talked out of — no matter what the "customer" says, data is treated as data, never as instructions.

⚠️ This is not theoretical

Injection-related breaches have exposed enormous amounts of data — from the Heartland Payment Systems breach (2008) to the Equifax incident (2017) affecting roughly 147 million people. The techniques in this lesson are the industry-standard defenses.

How SQL Injection Works

Injection happens when untrusted input is concatenated directly into a query string, so the database can no longer tell where your SQL ends and the attacker's begins. Consider this classic vulnerable login:

<?php
// DANGER — never do this
$username = $_POST['username'];
$sql = "SELECT * FROM users WHERE username = '$username'";
$result = $mysqli->query($sql);

If an attacker types ' OR '1'='1 into the username box, the string becomes:

SELECT * FROM users WHERE username = '' OR '1'='1'

Because '1'='1' is always true, the WHERE clause matches every row — the attacker is logged in, often as the first user, which is frequently an admin.

SQL injection attack flow An attacker submits malicious input to a form; the PHP app concatenates it into a query without sanitization; the database executes the altered query. Attacker ' OR '1'='1 PHP app concatenates input into the query Database runs the altered SQL SELECT * FROM users WHERE username = '' OR '1'='1'
Figure 1 — When input is concatenated into a query, the attacker's text becomes executable SQL.

The many faces of injection

AttackSample inputImpact
Auth bypass' OR '1'='1Log in as any user
UNION attack' UNION SELECT username, password FROM users-- Steal data from other tables
Data modification'; UPDATE users SET role='admin'-- Change stored data
Destruction'; DROP TABLE users-- Delete database objects

Prepared Statements: The Fix

A prepared statement separates the SQL structure from the data. You send the query with placeholders first; the database compiles that template; then you send the values separately. Because the template is already compiled, incoming values can only ever be data — never new SQL. Even ' OR '1'='1 is treated as a literal username string to search for, which simply doesn't exist.

sequenceDiagram participant A as PHP App participant D as MySQL Server Note over A,D: 1. Prepare A->>D: PREPARE "SELECT * FROM users WHERE username = ?" D-->>A: Template compiled & stored Note over A,D: 2. Bind A->>A: Bind value username = "johndoe" Note over A,D: 3. Execute A->>D: EXECUTE with bound value (data only) D-->>A: Result rows

✅ The core guarantee

With a prepared statement the query plan is fixed before any user data arrives. Data and code travel on separate channels, so user input can never change what the query does — only which rows it matches.

Implementing in MySQLi & PDO

PDO — named parameters (recommended)

<?php
$stmt = $pdo->prepare(
    'SELECT * FROM users WHERE username = :username AND status = :status'
);
$stmt->execute([
    ':username' => $_POST['username'],   // safe: bound as data
    ':status'   => 1,
]);

foreach ($stmt as $row) {
    echo "{$row['username']} — {$row['email']}\n";
}

PDO — positional parameters

<?php
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? AND status = ?');
$stmt->execute([$_POST['username'], 1]);
$users = $stmt->fetchAll();

MySQLi — object-oriented with type binding

<?php
$stmt = $mysqli->prepare('SELECT * FROM users WHERE username = ? AND status = ?');

// Type string: s = string, i = integer, d = double, b = blob
$stmt->bind_param('si', $_POST['username'], $status);
$status = 1;

$stmt->execute();
$result = $stmt->get_result();

while ($row = $result->fetch_assoc()) {
    echo "{$row['username']} — {$row['email']}\n";
}
$stmt->close();

💡 PDO vs MySQLi for prepared statements

  • PDO supports named placeholders (:username), auto-detects types, and reads cleanly in complex queries.
  • MySQLi uses positional ? only and requires you to declare each parameter's type in bind_param().
  • Both are equally secure against injection — pick the style that fits your project.

⚠️ Golden rule

Use a prepared statement for every value that comes from a user, a URL, a cookie, a header, or an API — even values that "look harmless" like a numeric id. Never build a query by concatenating input, not even once.

Secure Password Storage

Prepared statements protect the query; password hashing protects your users if the database is ever stolen. Never store plaintext passwords, and never use MD5 or SHA1 — they are far too fast for attackers to brute-force. PHP's built-in functions do the right thing by default.

<?php
// REGISTER — hash before storing (bcrypt by default, salt handled for you)
$hash = password_hash($_POST['password'], PASSWORD_DEFAULT);

$stmt = $pdo->prepare(
    'INSERT INTO users (username, password_hash) VALUES (:u, :h)'
);
$stmt->execute([':u' => $username, ':h' => $hash]);

// LOGIN — verify against the stored hash
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE username = :u');
$stmt->execute([':u' => $username]);
$user = $stmt->fetch();

if ($user && password_verify($_POST['password'], $user['password_hash'])) {
    echo 'Login OK';

    // Keep hashes current if PHP's default algorithm improves
    if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
        $newHash = password_hash($_POST['password'], PASSWORD_DEFAULT);
        $pdo->prepare('UPDATE users SET password_hash = :h WHERE id = :id')
            ->execute([':h' => $newHash, ':id' => $user['id']]);
    }
} else {
    echo 'Invalid username or password';   // don't reveal which was wrong
}

📖 The three functions to remember

password_hash() — turns a plaintext password into a salted bcrypt hash.

password_verify() — checks a plaintext attempt against a stored hash; returns true/false.

password_needs_rehash() — tells you when to upgrade an old hash to the current default.

Beyond Prepared Statements

Prepared statements are the primary defense, but real security is layered. Add these on top.

Validate input anyway

Prepared statements stop injection, but validation catches bad or malicious data earlier and gives users better feedback.

<?php
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if ($id === false || $id === null || $id <= 0) {
    http_response_code(400);
    exit('Invalid user ID.');
}
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute([':id' => $id]);

Whitelist dynamic identifiers

Placeholders can bind values, but not table or column names. If those must be dynamic (e.g. a sort column from the UI), check them against an allow-list.

<?php
// UNSAFE — a column name straight from the user
// $sql = "SELECT * FROM products ORDER BY " . $_GET['sort'];

// SAFE — only permit known-good columns
$allowed = ['name', 'price', 'created_at'];
$sort = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'name';
$sql  = "SELECT * FROM products ORDER BY $sort";   // $sort is now trusted
$rows = $pdo->query($sql)->fetchAll();
Do this (pattern)Not this (antipattern)Why
Prepared statements for all inputString concatenation of inputStops SQL injection
password_hash()Plaintext, MD5, or SHA1Protects credentials if breached
Generic user-facing errorsRaw SQL errors in the UIPrevents schema disclosure
Least-privilege DB accountApp connects as an adminLimits damage from a compromise
Whitelist dynamic identifiersTable/column names from user inputBlocks schema-based attacks

A Secure Data-Access Layer

Centralizing database work in one class means the safe way is the only way — every query in your app automatically goes through prepared statements.

<?php
class Database
{
    private PDO $conn;

    public function __construct()
    {
        $dsn = sprintf(
            'mysql:host=%s;dbname=%s;charset=utf8mb4',
            getenv('DB_HOST') ?: 'localhost',
            getenv('DB_NAME')
        );
        $this->conn = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASS'), [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES   => false,   // real prepared statements
        ]);
    }

    // Always prepared — callers can't bypass it
    public function select(string $sql, array $params = []): array
    {
        $stmt = $this->conn->prepare($sql);
        $stmt->execute($params);
        return $stmt->fetchAll();
    }

    public function insert(string $table, array $data): string
    {
        $cols = implode(', ', array_keys($data));
        $ph   = ':' . implode(', :', array_keys($data));
        $stmt = $this->conn->prepare("INSERT INTO $table ($cols) VALUES ($ph)");
        $stmt->execute($data);
        return $this->conn->lastInsertId();
    }

    public function beginTransaction(): bool { return $this->conn->beginTransaction(); }
    public function commit(): bool           { return $this->conn->commit(); }
    public function rollBack(): bool         { return $this->conn->rollBack(); }
}
<?php
$db = new Database();

// Safe read — params are bound automatically
$users = $db->select(
    'SELECT id, username FROM users WHERE status = :s',
    [':s' => 1]
);

// Safe write returning the new id
$newId = $db->insert('users', [
    'username'      => 'newuser',
    'email'         => 'new@example.com',
    'password_hash' => password_hash('secret123', PASSWORD_DEFAULT),
]);

// A transaction: both inserts succeed or neither does
$db->beginTransaction();
try {
    $orderId = $db->insert('orders', ['user_id' => 1, 'total' => 99.99]);
    $db->insert('order_items', ['order_id' => $orderId, 'product_id' => 42, 'qty' => 2]);
    $db->commit();
} catch (Throwable $e) {
    $db->rollBack();
    error_log($e->getMessage());
}

💡 Note the transaction

A transaction groups multiple writes so they all succeed or all roll back together. Combined with a table like order_items that references an order_id, it keeps related NoSQL-vs-SQL data consistent — you never end up with an order that has no items because the second insert failed.

Hands-on Exercise

🏋️ Harden a vulnerable login

Objective: Turn an injectable query into a safe prepared statement and confirm the attack fails.

Instructions:

  1. Start from the vulnerable login that concatenates $_POST['username'] and $_POST['password'] into the SQL string.
  2. Rewrite it to (a) look up the user with a prepared statement by username only, and (b) verify the password with password_verify() against the stored hash.
  3. Test with the payload ' OR '1'='1 in the username field and confirm it now finds no user.
  4. Return the same generic message — "Invalid username or password" — whether the username or the password was wrong.
💡 Hint

Never put the password into the SQL at all. Fetch the row by username, then compare with password_verify($_POST['password'], $row['password_hash']). Because the prepared statement binds the username as data, ' OR '1'='1 becomes a literal username to search for — and no such user exists.

✅ Sample solution
<?php
// secure_login.php
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';

$stmt = $pdo->prepare(
    'SELECT id, username, password_hash FROM users WHERE username = :u'
);
$stmt->execute([':u' => $username]);   // ' OR '1'='1 is just a literal string here
$user = $stmt->fetch();

if ($user && password_verify($password, $user['password_hash'])) {
    session_start();
    session_regenerate_id(true);       // prevent session fixation
    $_SESSION['user_id']  = $user['id'];
    $_SESSION['username'] = $user['username'];
    header('Location: /dashboard.php');
    exit;
}

// Same message for both failure modes — don't help attackers enumerate users
echo 'Invalid username or password';

🎯 Quick Quiz

Question 1: Why does a prepared statement stop SQL injection?

Question 2: A user-chosen sort column must be inserted into a query. What's the safe approach?

Question 3: How should passwords be stored?

Summary & Quiz

🎉 Key Takeaways

  • SQL injection happens when user input is concatenated into queries; it remains one of the top web vulnerabilities.
  • Prepared statements are the primary defense — they compile the query first, so bound values are always data, never code.
  • Both PDO (named/positional) and MySQLi (positional + type binding) support them; use them for every piece of untrusted input.
  • Store passwords with password_hash() and check with password_verify() — never plaintext, MD5, or SHA1.
  • Placeholders can't bind identifiers — whitelist any dynamic table or column names.
  • Centralize protections in a secure data-access layer, apply least privilege, and use transactions for related writes.

📚 Further Reading

🚀 What's Next?

You've secured the MySQL side of the world. Next we broaden our horizons: PostgreSQL Features and Setup introduces the other giant of open-source relational databases and the powerful features that set it apart.

🎉 You're a safer developer now!

Prepared statements, hashed passwords, and a hardened data layer put you ahead of a huge share of real-world code.