Most hacks of PHP sites don't come from complex tricks. They come from three simple mistakes: user data is placed directly into an SQL query, printed on the page without escaping, or a request is accepted without checking where it came from. This article shows all three with vulnerable and fixed code. The examples are only for understanding and defending your own code.
The general rule: everything that comes from outside, from forms, URLs, cookies and headers to uploaded files, is untrusted until proven otherwise.
SQL injection#
Vulnerable code#
<?php
// Vulnerable: input goes straight into the SQL
$id = $_GET['id'];
$result = $pdo->query("SELECT * FROM users WHERE id = $id");If the user sends a piece of SQL in id instead of a number, that piece becomes part of the statement and can read or change data.
The right way: parameterized queries#
<?php
declare(strict_types=1);
$pdo = new PDO(
'mysql:host=localhost;dbname=app;charset=utf8mb4',
$dbUser,
$dbPass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if ($id === false || $id === null) {
http_response_code(400);
exit('Invalid request');
}
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE id = :id');
$stmt->execute(['id' => $id]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);Data and statement are sent separately, so whatever is in id is just a value. In WordPress the equivalent is $wpdb->prepare():
<?php
global $wpdb;
$row = $wpdb->get_row(
$wpdb->prepare("SELECT * FROM {$wpdb->prefix}orders WHERE id = %d", $orderId)
);In Laravel too, Eloquent and the Query Builder are parameterized by default; problems come from DB::raw and whereRaw with pasted-in input.
Table and column names can't be parameterized. If you take the sort order from the user, choose only from an allowed list.
XSS#
In XSS, an attacker enters text or a script that later runs in other people's browsers.
Vulnerable#
<?php
echo "<p>Hello " . $_GET['name'] . "</p>";If name contains a <script> tag, it runs on the page and can steal cookies and the session.
Fixed#
<?php
declare(strict_types=1);
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$name = (string) ($_GET['name'] ?? '');
echo '<p>Hello ' . e($name) . '</p>';Escaping must match where the value is printed:
| Where it's printed | Right tool |
|---|---|
| Text inside HTML | htmlspecialchars, or esc_html() in WordPress |
| Attribute value | esc_attr() in WordPress, or htmlspecialchars with ENT_QUOTES |
| URL | esc_url() in WordPress |
| Blade template | {{ $x }} escapes automatically |
Note that {!! $x !!} in Blade and a raw echo in WordPress do not escape. In addition, a Content-Security-Policy header gives another layer of defense, but it doesn't replace escaping.
CSRF#
CSRF means another site uses your open session to send a request to a site. The browser sends the cookie by itself, and the server thinks you clicked.
A CSRF token in plain PHP#
<?php
declare(strict_types=1);
session_start();
function csrfToken(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function csrfCheck(?string $token): bool
{
return is_string($token)
&& isset($_SESSION['csrf'])
&& hash_equals($_SESSION['csrf'], $token);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrfCheck($_POST['_token'] ?? null)) {
http_response_code(419);
exit('Invalid token');
}
// continue with safe processing
}In the form, add a hidden field with the value of csrfToken() and escape it. Using hash_equals avoids timing-based comparison.
In WordPress and Laravel#
- WordPress:
wp_nonce_field('save_item')in the form andcheck_admin_referer('save_item')orwp_verify_noncewhen processing. After that, always check permission withcurrent_user_can()too. - Laravel: the
@csrfdirective in forms; the default middleware does the rest.
Accept requests that change something only via POST (or PUT and DELETE), not GET.
A few related rules#
- Store passwords with
password_hash()and verify them withpassword_verify(), not MD5. - Change the session ID after login with
session_regenerate_id(true). - Don't display PHP errors on a live site; write them to a log.
- Save uploaded files with an allowed type and extension, and a name you generate yourself.
- Keep dependencies updated; the
composer auditpart is explained in Composer and dependency management.
For features that make writing safe code easier, see modern PHP. If your site runs WordPress, the WordPress security checklist and building a WordPress plugin from scratch complement this article.
A complete example: a secure form#
If we put the three rules together, receiving a contact message looks like this. Notice that each layer does its own job: input validation, token check, parameterized query and escaping on display.
<?php
declare(strict_types=1);
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrfCheck($_POST['_token'] ?? null)) {
http_response_code(419);
exit('Invalid token');
}
$name = trim((string) ($_POST['name'] ?? ''));
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
$text = trim((string) ($_POST['text'] ?? ''));
if ($name === '' || $email === false || $text === '') {
http_response_code(422);
exit('Missing information');
}
$stmt = $pdo->prepare(
'INSERT INTO messages (name, email, body) VALUES (:n, :e, :b)'
);
$stmt->execute(['n' => $name, 'e' => $email, 'b' => $text]);
}In the admin page where you read the messages, escape every value with the e() function. Even if storing the data was safe, displaying it must be made safe separately.
How to check your own code#
For a quick review, ask these questions of every file:
- Which variables come from
$_GET,$_POST,$_COOKIE,$_FILESor headers, and how far do they travel? - Is every query that uses input parameterized?
- Does every print of non-constant data have escaping?
- Does every state-changing operation have a token and a permission check?
- Is a technical error message shown to the user?
A "no" to any of these marks the place to inspect first. Static analysis tools such as PHPStan with security extensions, or free scanners, automate parts of this work, but they don't fully replace a human eye.
Summary#
- Input from anywhere and any source is untrusted; validate it.
- Stop SQL injection with parameterized queries (PDO,
$wpdb->prepare, Eloquent). - Stop XSS by escaping output to match where it is printed.
- Stop CSRF with a token or nonce plus a user permission check.
- Keep passwords with
password_hashand write errors to a log, not to the page. - Don't bypass the framework's tools; raw code is usually where the holes are.