SQL injection, XSS, CSRF, password storage, sessions, file uploads and error settings: the most common holes in PHP projects, with code examples for each fix.
The security holes in the PHP projects I inherit are rarely exotic. I don’t run into movie-style attacks with green text streaming down the screen. I run into mistakes that have been known for fifteen years, have names and have well-known fixes. That’s the bad news. It’s also the good news: someone who knows the list can close most of them in an afternoon.
The eight items below are what I check first when I take over a project. The examples are plain PHP. If you use a framework like Laravel or Symfony, it already handles most of this, but knowing how it does so protects you the moment you step outside the framework.
Short answer: run queries as prepared statements, escape output for its context, add CSRF tokens to forms, store passwords with password_hash, harden session cookies, trust nothing about uploaded files, never load files or objects from user input, and never print errors in production. Also keep PHP and your dependencies up to date.
1. SQL injection: don’t build queries by gluing strings
Still number one, for the same old reason:
// Don't
$sql = "SELECT * FROM users WHERE email = '" . $_POST['email'] . "'";
If the user’s text becomes part of the query, the user is writing the query. The fix is prepared statements, which keep the data apart from the query:
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE email = ?');
$stmt->execute([$_POST['email']]);
One catch: table and column names can’t be bound as parameters. If you sort by a column the user picks with ORDER BY, check the incoming value against an allow list:
$sort = in_array($_GET['sort'] ?? '', ['name', 'created_at'], true) ? $_GET['sort'] : 'created_at';
2. XSS: always escape output
Print user text onto the page as-is, and the day that text is a <script> tag, code runs in your visitor’s browser. Session cookies get stolen, fake forms get shown. The rule is simple: validate data on the way in, escape it on the way out.
function e(string $s): string {
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
echo '<p>' . e($comment) . '</p>';
Template engines do this for you: {{ }} in Blade, the default behaviour in Twig. In Smarty, though, auto-escaping is off by default; turn it on with $smarty->setEscapeHtml(true) or add |escape to your variables. Worth remembering when building WHMCS and WiseCP themes.
As an extra layer, a Content-Security-Policy header makes it harder for an injected script to run. It doesn’t replace escaping, but it can save you on the one spot you missed.
3. CSRF: prove the form came from you
A user is logged in to your site and visits another one. That site quietly submits a “change email address” form to your site, and the browser sends the cookie along. The defence is a random, session-bound token in every form:
// When showing the form
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
echo '<input type="hidden" name="csrf" value="' . e($_SESSION['csrf']) . '">';
// When handling the form
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(403);
exit;
}
hash_equals stops the comparison time from leaking information. SameSite=Lax cookies add another layer.
4. Passwords: password_hash, not md5
In 2026 I still see md5($password). md5 and sha1 were designed to be fast, which is exactly what you don’t want for storing passwords. PHP’s own functions handle salting and algorithm choice for you:
$hash = password_hash($password, PASSWORD_DEFAULT);
if (password_verify($input, $hash)) {
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
// Save the new hash
}
}
PASSWORD_DEFAULT can move to a stronger algorithm over time, and password_needs_rehash upgrades old hashes as users log in. Make the hash column at least 255 characters.
5. Sessions: harden the cookie, renew the ID
session_set_cookie_params([
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
// Right after a successful login
session_regenerate_id(true);
httponly stops JavaScript from reading the session cookie. session_regenerate_id closes the scenario where an attacker logs a user in with a session ID the attacker already knows (session fixation). In php.ini, keep session.use_strict_mode = 1 on as well.
6. File uploads: trust nothing
An upload form is the shortest path for an attacker to leave code on your server. You can trust neither the file extension nor $_FILES['type'], since the client sets both. A safe flow:
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($_FILES['photo']['tmp_name']);
if (!isset($allowed[$mime]) || $_FILES['photo']['size'] > 5 * 1024 * 1024) {
throw new RuntimeException('Invalid file');
}
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
move_uploaded_file($_FILES['photo']['tmp_name'], '/var/app/uploads/' . $name);
Three rules: determine the type from the file’s content, generate the file name yourself, and store the file outside the web root if you can. If you can’t, turn off PHP execution in the upload folder at the web server. In Nginx this rule must come before the general .php rule, because the first matching regular expression wins:
location ~* ^/uploads/.*\.php$ {
deny all;
}
7. Loading files or objects from user input
Two old classics that are still alive:
include $_GET['page'] . '.php'; // Don't
unserialize($_COOKIE['cart']); // Don't do this either
The first can let an attacker read or run other files on the server; the fix is again an allow list that matches the value against known page names. The second builds PHP objects from data the user controls, and with the right classes around it can go as far as code execution. Use json_decode to carry data. If you must use unserialize, pass ['allowed_classes' => false].
8. Production settings, version and dependencies
Error messages are your friend during development and an attacker’s map in production. File paths, queries, even the database user name can end up on screen:
display_errors = Off
log_errors = On
expose_php = Off
And the most boring but most effective measure: staying current. A PHP version past its end of life no longer gets security fixes; check which versions are supported on php.net’s “Supported Versions” page. If you use Composer, one command shows known holes in your dependencies:
composer audit
An afternoon’s checklist
- No SQL query is built by gluing strings
- Every piece of user-supplied output is escaped
- Every state-changing form has a CSRF token
- Passwords are stored with
password_hash - Session cookies are marked
secure,httponlyandsamesite - Upload types are checked by content and PHP doesn’t run in the upload folder
includeandunserializenever see user inputdisplay_errorsis off in production, the PHP version is supported,composer auditis clean
If every item is ticked, you’re not an interesting target for most bots and curious students. That doesn’t mean you’re completely safe, of course. Security isn’t a job you finish once, it’s regular maintenance.
To harden the server PHP runs on, see the first 30 minutes on a new Linux server. For a security review of an existing project, write to me through the quote form.