Published on August 19th, 2026
PDO gives you the tools to write secure database code, but it doesn't make your queries safe by default. Here's how prepared statements actually stop SQL injection, the mistakes that quietly reopen the door, and the connection-level hardening most PHP apps skip.
Contact mePDO is often described as "the secure way" to talk to a database in PHP, and compared to the old mysql_* functions, it is. But PDO is a tool, not a guarantee. It's entirely possible to use PDO and still ship a SQL injection vulnerability — string-concatenated queries, disabled emulation quirks, or a database user with far more privileges than the application needs. This article covers what actually makes PDO code secure, and the mistakes that undermine it.
SQL injection happens when untrusted input is concatenated directly into a query string, letting an attacker change the structure of the SQL itself:
<?php
// NEVER do this
$email = $_GET['email'];
$sql = "SELECT * FROM users WHERE email = '$email'";
$result = $pdo->query($sql);
// Input like ' OR '1'='1 returns every row in the table
?>
Prepared statements fix this by separating the SQL structure from the data. The query is compiled first, then values are bound afterwards — user input is never parsed as SQL, so it can't alter the query's shape:
<?php
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email");
$stmt->execute(['email' => $_GET['email']]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
?>
This is the single most important habit in PDO security: never build a query string with user input, ever — always bind it as a parameter.
A common mistake is trying to bind a column name, table name, or sort direction as a parameter. Placeholders only work for values, not for SQL keywords or identifiers — PDO will either throw an error or silently produce broken SQL:
<?php
// This does NOT work — you cannot parameterize a column name
$stmt = $pdo->prepare("SELECT * FROM users ORDER BY :column");
$stmt->execute(['column' => 'created_at']);
?>
When a value like a sort column has to come from user input, validate it against a strict allow-list before interpolating it — never trust it directly:
<?php
$allowedColumns = ['name', 'email', 'created_at'];
$column = in_array($_GET['sort'] ?? '', $allowedColumns, true) ? $_GET['sort'] : 'created_at';
$stmt = $pdo->prepare("SELECT * FROM users ORDER BY {$column}");
$stmt->execute();
?>
By default, PDO's MySQL driver emulates prepared statements in PHP rather than sending them to the database server. Emulation still protects against injection in the common case, but it has historically been the source of driver-specific bugs, and it disables some of the type safety real, server-side prepares give you. Turn it off:
<?php
$pdo = new PDO($dsn, $username, $password, [
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
?>
With emulation disabled, the query and its parameters are sent to the database separately, which is the strongest guarantee against injection that PDO can offer.
PDO::ERRMODE_EXCEPTION is the right default, but uncaught exceptions can end up printed straight to the browser, revealing table names, column names, or even fragments of your query — useful reconnaissance for an attacker. Catch database exceptions at a boundary, log the details, and show the user something generic:
<?php
try {
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email");
$stmt->execute(['email' => $email]);
} catch (PDOException $e) {
error_log($e->getMessage());
http_response_code(500);
echo "Something went wrong. Please try again later.";
exit;
}
?>
Also make sure display_errors is off in production — a raw PDOException stack trace can expose your DSN, including host and database name.
Prepared statements protect individual queries, but they don't limit what a compromised connection can do. If the application only ever needs to read and write to a handful of tables, its database user shouldn't have DROP, ALTER, or access to unrelated schemas. Scope the account tightly, for example in MySQL:
CREATE USER 'app_user'@'%' IDENTIFIED BY 'strong-random-password';
GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.* TO 'app_user'@'%';
FLUSH PRIVILEGES;
This limits the blast radius if a query does slip through with an injection flaw — the attacker still can't touch other databases or run administrative commands.
DSN, username, and password should come from environment variables or a secrets manager, never hardcoded or committed to version control:
<?php
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=utf8mb4',
$_ENV['DB_HOST'],
$_ENV['DB_NAME']
);
$pdo = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASSWORD'], [
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
?>
Binding values into a LIKE clause is safe from injection, but forgetting to escape the user's own % and _ wildcard characters can let them run unexpectedly broad searches:
<?php
$search = str_replace(['%', '_'], ['\%', '\_'], $_GET['q']);
$stmt = $pdo->prepare("SELECT * FROM products WHERE name LIKE :search ESCAPE '\\\\'");
$stmt->execute(['search' => '%' . $search . '%']);
?>
PDO::ATTR_EMULATE_PREPARES to false so queries are prepared server-side.PDO::ATTR_ERRMODE to PDO::ERRMODE_EXCEPTION and catch it — don't let raw errors reach the response.LIKE search terms.PDO gives PHP developers everything needed to write secure database code, but security comes from how it's used, not from the library itself. Bind every value, validate every identifier, disable emulated prepares, keep errors out of the response, and run queries under a database account with the least privilege that still gets the job done. Get those habits right and SQL injection stops being a risk you have to think about on every query.
Looking for a skilled PHP developer to bring your project to life? I specialize in creating robust and efficient PHP solutions tailored to your needs.
Contact me