zusammen denken, nicht gegeneinander abwägen
Prepared Statements werden oft nur als Schutz vor SQL-Injection verstanden, sind aber gleichzeitig ein Performance-Werkzeug für wiederholt ausgeführte Abfragen. Dieser Artikel erklärt, warum String-Concatenation strukturell unsicher ist, wie Prepared Statements dieses Problem lösen, was der Unterschied zwischen Server-Side und Client-Side Prepare ist, und zeigt korrekte Implementierungen mit PDO und mysqli inklusive typischer Fallstricke.
Inhaltsverzeichnis
- 1. SQL-Injection verstehen: Warum String-Concatenation gefährlich ist
- 2. Wie Prepared Statements SQL-Injection strukturell verhindern
- 3. Server-Side vs. Client-Side Prepare in MySQL
- 4. Prepared Statements mit PDO korrekt einsetzen
- 5. Prepared Statements mit mysqli
- 6. Performance: Statement-Caching und wiederholte Ausführung
- 7. Fallstricke: Dynamische Identifier, IN-Klauseln, Bulk-Inserts
- 8. Prepared Statements und die Interaktion mit dem Plan-Cache
- 9. String-Concatenation und Prepared Statements im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. SQL-Injection verstehen: Warum String-Concatenation gefährlich ist
SQL-Injection entsteht, wenn Nutzereingaben direkt in einen SQL-String eingefügt werden, statt sie als Daten von der SQL-Syntax zu trennen. Ein Login-Formular, das die Eingabe des Nutzers ungeprüft in eine WHERE-Klausel einfügt, interpretiert eine Eingabe wie ' OR '1'='1 nicht als Textwert, sondern als Teil der SQL-Syntax selbst, wodurch die Authentifizierungslogik ausgehebelt wird. Prepared Statements lösen dieses Problem an der Wurzel, nicht durch Filterung gefährlicher Zeichen, sondern durch strukturelle Trennung von Code und Daten.
Das grundlegende Problem der String-Concatenation ist, dass der Datenbankserver niemals zwischen SQL-Syntax und Nutzereingabe unterscheiden kann, wenn beides im selben String vermischt wird. Escaping-Funktionen wie mysqli_real_escape_string() versuchen dieses Problem nachträglich zu reparieren, sind aber fehleranfällig, etwa bei bestimmten Zeichenkodierungen oder wenn Entwickler eine einzelne Stelle im Code vergessen. Prepared Statements machen diese Fehlerklasse strukturell unmöglich, weil Daten und SQL-Struktur getrennte Kanäle zum Server nutzen.
-- VULNERABLE: user input concatenated directly into SQL string
-- If $username = "' OR '1'='1", the query becomes:
SELECT * FROM users WHERE username = '' OR '1'='1' AND password = '...';
-- Authentication bypassed entirely, regardless of the password
-- SAFE: prepared statement with a placeholder
-- The value is sent separately, never parsed as SQL syntax
PREPARE stmt FROM 'SELECT * FROM users WHERE username = ? AND password = ?';
SET @user = 'admin', @pass = 'hashed_value';
EXECUTE stmt USING @user, @pass;
DEALLOCATE PREPARE stmt;
2. Wie Prepared Statements SQL-Injection strukturell verhindern
Ein Prepared Statement läuft in zwei getrennten Phasen ab. In der Prepare-Phase sendet die Anwendung die SQL-Struktur mit Platzhaltern, typischerweise als Fragezeichen oder benannte Parameter, an den Datenbankserver. Der Server parst diese Struktur und erstellt einen Ausführungsplan, noch bevor irgendwelche tatsächlichen Werte bekannt sind. In der Execute-Phase werden die konkreten Werte separat übertragen und ausschließlich als Daten in die bereits feststehenden Platzhalter eingesetzt, niemals als Teil der SQL-Syntax interpretiert.
Diese Trennung macht es unmöglich, dass ein Wert wie ' OR '1'='1 die SQL-Struktur verändert, weil die Struktur zum Zeitpunkt der Werteübergabe bereits final feststeht. Selbst wenn eine Eingabe SQL-Metazeichen wie Anführungszeichen oder Semikolons enthält, werden sie als reiner Textinhalt des Parameters behandelt. Prepared Statements bieten damit einen strukturellen Schutz, der nicht von der Sorgfalt einzelner Entwickler abhängt, im Gegensatz zu manuellem Escaping, das an jeder einzelnen Stelle im Code korrekt angewendet werden müsste.
3. Server-Side vs. Client-Side Prepare in MySQL
MySQL unterstützt zwei Varianten von Prepared Statements. Beim echten Server-Side Prepare, wie es der native MySQL-Protokoll-Modus und mysqli standardmäßig nutzen, sendet der Client die Prepare-Anfrage tatsächlich an den Server, der die Struktur parst, validiert und einen Ausführungsplan vorbereitet, der bei wiederholter Ausführung wiederverwendet werden kann. Das reduziert Parsing-Overhead bei mehrfacher Ausführung derselben Struktur mit unterschiedlichen Werten.
PDO nutzt standardmäßig hingegen Client-Side Prepare, auch Emulated Prepares genannt, bei dem der PHP-Treiber die Platzhalter selbst durch escapte Werte ersetzt und den fertigen String an den Server sendet, ohne einen echten Server-Side-Prepare-Zyklus zu durchlaufen. Der Sicherheitsvorteil gegenüber SQL-Injection bleibt dabei vollständig erhalten, solange die PDO-eigene Ersetzung korrekt implementiert ist, der Performance-Vorteil des Plan-Caching auf Serverseite entfällt jedoch. Über PDO::ATTR_EMULATE_PREPARES = false lässt sich echtes Server-Side Prepare erzwingen.
<?php
declare(strict_types=1);
// Force real server-side prepared statements in PDO
$pdo = new PDO($dsn, $user, $password, [
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
// Without this flag, PDO emulates prepares client-side by default,
// which is still safe against SQL injection but skips server-side plan caching
4. Prepared Statements mit PDO korrekt einsetzen
Die korrekte Nutzung von Prepared Statements mit PDO folgt einem festen Muster: prepare() mit Platzhaltern aufrufen, dann execute() mit einem Array der tatsächlichen Werte. Benannte Platzhalter wie :username verbessern die Lesbarkeit gegenüber positionellen Fragezeichen-Platzhaltern erheblich, besonders bei Abfragen mit vielen Parametern, weil die Zuordnung nicht von der exakten Reihenfolge abhängt.
Ein häufiger Fehler ist, Platzhalter für Tabellennamen oder Spaltennamen zu verwenden, was nicht funktioniert, da Prepared Statements ausschließlich Werte parametrisieren können, nicht die SQL-Struktur selbst. Dynamische Identifier müssen stattdessen über eine strikte Allowlist geprüft und direkt, aber niemals aus Nutzereingaben unvalidiert, in den SQL-String eingefügt werden.
<?php
declare(strict_types=1);
final class UserRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* Finds a user by email using a named-placeholder prepared statement.
*/
public function findByEmail(string $email): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, email, name FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row === false ? null : $row;
}
/**
* Sorting by a dynamic column: never interpolate user input directly,
* validate against an allowlist since placeholders cannot parametrize identifiers.
*/
public function findAllSorted(string $sortColumn): array
{
$allowed = ['id', 'email', 'created_at'];
if (!in_array($sortColumn, $allowed, true)) {
throw new InvalidArgumentException("Invalid sort column: {$sortColumn}");
}
// Safe: $sortColumn is validated against a strict allowlist, not user-controlled SQL
$stmt = $this->pdo->query("SELECT id, email, name FROM users ORDER BY {$sortColumn}");
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}
5. Prepared Statements mit mysqli
Die mysqli-Erweiterung bietet eine etwas umständlichere, aber ebenso sichere API für Prepared Statements. Nach mysqli_prepare() werden Platzhalter über bind_param() mit einem Typ-String gebunden, wobei i für Integer, s für String, d für Double und b für Blob steht. Dieser Typ-String muss exakt zur Anzahl und Reihenfolge der Platzhalter passen, ein häufiger Fehlerquelle bei manueller Pflege längerer Abfragen.
Seit PHP 8.1 unterstützt mysqli zusätzlich execute() mit direkter Werteübergabe als Array, was den expliziten bind_param()-Aufruf überflüssig macht und die API näher an PDO heranführt. Für neue Projekte ist PDO aufgrund der treiberunabhängigen API und der saubereren Named-Placeholder-Unterstützung meist die bessere Wahl, mysqli bleibt aber relevant für Legacy-Code und Szenarien mit spezifischen MySQL-Funktionen wie mehreren Result-Sets aus gespeicherten Prozeduren.
<?php
declare(strict_types=1);
$mysqli = new mysqli('db.internal', 'app_user', $password, 'shop');
$mysqli->set_charset('utf8mb4');
// Classic mysqli prepared statement with bind_param
$stmt = $mysqli->prepare('SELECT id, name, price FROM product WHERE category_id = ? AND price < ?');
$stmt->bind_param('id', $categoryId, $maxPrice); // i = int, d = double
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
echo $row['name'] . PHP_EOL;
}
$stmt->close();
// PHP 8.1+: execute() accepts parameters directly
$stmt = $mysqli->prepare('SELECT id FROM product WHERE sku = ?');
$stmt->execute(['A-100']);
6. Performance: Statement-Caching und wiederholte Ausführung
Der Performance-Vorteil echter Server-Side Prepared Statements zeigt sich bei wiederholter Ausführung derselben Struktur mit unterschiedlichen Werten. MySQL muss die SQL-Syntax nur einmal parsen, validieren und einen Ausführungsplan erstellen, danach wird für jede weitere Ausführung nur der bereits vorbereitete Plan mit neuen Parameterwerten genutzt. Bei einer Schleife, die dieselbe Abfrage tausendfach mit unterschiedlichen IDs ausführt, spart das messbar CPU-Zeit auf dem Datenbankserver.
Wichtig ist, dass dieser Vorteil nur innerhalb derselben Verbindung und Session gilt, ein vorbereitetes Statement wird nicht automatisch über verschiedene Verbindungen hinweg geteilt. In Kombination mit Connection-Pooling, bei dem Verbindungen über viele Anfragen hinweg wiederverwendet werden, potenziert sich der Vorteil, weil dieselbe vorbereitete Struktur über die gesamte Lebensdauer einer wiederverwendeten Verbindung genutzt werden kann, statt bei jeder neuen Anfrage erneut geparst zu werden.
-- Server-side prepare once, execute many times within the same connection
PREPARE stmt FROM 'UPDATE inventory SET quantity = quantity - ? WHERE product_id = ?';
-- Each EXECUTE reuses the already parsed plan, only new parameter values are sent
SET @qty = 1, @pid = 100;
EXECUTE stmt USING @qty, @pid;
SET @qty = 2, @pid = 101;
EXECUTE stmt USING @qty, @pid;
DEALLOCATE PREPARE stmt;
7. Fallstricke: Dynamische Identifier, IN-Klauseln, Bulk-Inserts
Eine häufige Falle bei Prepared Statements ist die IN (?)-Klausel mit einer variablen Anzahl von Werten. Ein einzelner Platzhalter kann nicht für mehrere Werte gleichzeitig stehen, weshalb die Platzhalteranzahl dynamisch an die Anzahl der tatsächlichen Werte angepasst werden muss, etwa durch das Generieren von ?, ?, ? entsprechend der Array-Länge, statt fälschlicherweise die Werte kommasepariert in einen einzigen Platzhalter zu schreiben.
Bei Bulk-Inserts mit vielen Zeilen ist es effizienter, ein einzelnes INSERT-Statement mit mehreren Werte-Tupeln und entsprechend vielen Platzhaltern vorzubereiten, statt für jede Zeile ein separates execute() aufzurufen. Das reduziert die Anzahl der Netzwerk-Roundtrips drastisch, bleibt aber vollständig sicher, da weiterhin jeder einzelne Wert über einen Platzhalter gebunden wird.
<?php
declare(strict_types=1);
// Dynamic IN clause: generate one placeholder per value
$ids = [12, 45, 78, 103];
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT id, name FROM product WHERE id IN ({$placeholders})");
$stmt->execute($ids);
// Bulk insert: one statement, multiple value tuples, still fully parameterized
$rows = [
['A-100', 'Widget', 9.99],
['A-101', 'Gadget', 19.99],
['A-102', 'Gizmo', 14.99],
];
$valuePlaceholders = implode(',', array_fill(0, count($rows), '(?, ?, ?)'));
$flatValues = array_merge(...$rows);
$stmt = $pdo->prepare("INSERT INTO product (sku, name, price) VALUES {$valuePlaceholders}");
$stmt->execute($flatValues);
8. Prepared Statements und die Interaktion mit dem Plan-Cache
MySQL verfügt seit Version 8.0 nicht mehr über den klassischen Query Cache, der in früheren Versionen entfernt wurde, aber der interne Optimizer erstellt für jedes Server-Side Prepared Statement einen wiederverwendbaren Ausführungsplan innerhalb der jeweiligen Session. Dieser Plan bleibt bestehen, solange die Verbindung aktiv ist und das Statement nicht mit DEALLOCATE PREPARE freigegeben wird, wird aber pro Verbindung getrennt gehalten, nicht global über den Server geteilt.
Bei stark variierenden Datenverteilungen kann ein einmal erstellter Ausführungsplan suboptimal werden, wenn sich die Statistiken der zugrunde liegenden Tabellen inzwischen deutlich verändert haben. MySQL berücksichtigt das über adaptive Neuoptimierung bei bestimmten Bedingungen, in den meisten Webanwendungen mit gleichmäßiger Datenverteilung ist dieser Effekt aber vernachlässigbar gegenüber dem Performance-Gewinn durch die Wiederverwendung des Plans.
9. String-Concatenation und Prepared Statements im Vergleich
Die folgende Tabelle stellt String-Concatenation und Prepared Statements in den relevanten Dimensionen gegenüber.
| Kriterium | String-Concatenation | Prepared Statements |
|---|---|---|
| SQL-Injection-Schutz | Kein struktureller Schutz | Strukturell ausgeschlossen |
| Wiederholte Ausführung | Jedes Mal neu geparst | Plan wird wiederverwendet |
| Lesbarkeit des Codes | Werte im String verstreut | Klare Trennung von Struktur und Daten |
| Dynamische Identifier | Direkt möglich, aber riskant | Nicht parametrisierbar, Allowlist nötig |
| Wartbarkeit | Fehleranfällig bei Änderungen | Robust gegenüber Refactoring |
In praktisch jedem realistischen Szenario überwiegen die Vorteile von Prepared Statements so eindeutig, dass String-Concatenation für Nutzereingaben in produktivem Code als grundsätzlich fehlerhaftes Muster gelten sollte, unabhängig von der Größe der Anwendung.
10. Zusammenfassung
Prepared Statements lösen zwei Probleme gleichzeitig, die oft getrennt betrachtet werden: SQL-Injection wird durch die strukturelle Trennung von SQL-Syntax und Nutzerdaten verhindert, und wiederholte Ausführungen derselben Abfragestruktur profitieren vom wiederverwendeten Ausführungsplan auf Serverseite. PDO nutzt standardmäßig Client-Side Prepare, was für die Sicherheit ausreicht, aber den Performance-Vorteil des serverseitigen Plan-Caching nur mit PDO::ATTR_EMULATE_PREPARES = false voll ausschöpft.
Typische Fallstricke wie dynamische IN-Klauseln, Bulk-Inserts und der Versuch, Tabellennamen zu parametrisieren, lassen sich mit den richtigen Mustern sauber lösen, ohne die Sicherheitsvorteile aufzugeben. Wer konsequent Prepared Statements für jede Abfrage mit externen Werten einsetzt, eliminiert die häufigste und zugleich gefährlichste Schwachstellenklasse in datenbankgestützten Anwendungen strukturell, statt sie nur durch Disziplin zu vermeiden.
Prepared Statements: Sicherheit und Performance, das Wichtigste auf einen Blick
Strukturelle Sicherheit
Platzhalter trennen SQL-Struktur und Daten, SQL-Injection wird unabhängig von Escaping ausgeschlossen.
Server-Side Prepare
Mit PDO::ATTR_EMULATE_PREPARES = false echte Plan-Wiederverwendung auf Serverseite erzwingen.
Dynamische Werte-Listen
Bei IN-Klauseln die Platzhalteranzahl dynamisch generieren, niemals Werte kommasepariert in einen Platzhalter schreiben.
Identifier per Allowlist
Tabellen- und Spaltennamen können nicht parametrisiert werden, immer gegen eine feste Allowlist prüfen.