PHP security checklist: the 8 holes I find most in real projects

Semih6 min read

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.

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, httponly and samesite
  • Upload types are checked by content and PHP doesn’t run in the upload folder
  • include and unserialize never see user input
  • display_errors is off in production, the PHP version is supported, composer audit is 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.

More from the rulebook

  1. 5 min read

    Coding with AI: what I hand over, and what I never do

    Notes from a developer who uses AI coding assistants every day. Which jobs to hand over, which not to, the traps, and a safe way of working.

  2. 5 min read

    Tuning PHP-FPM: how to calculate pm.max_children

    Set PHP-FPM's pm.max_children by measuring, not guessing. Measuring process memory, the formula, pm modes, the status page and the slowlog.

Chance

Got a project in mind? Let’s open the box: tell me what you want to build and I’ll prepare a proposal with the scope and a roadmap.

Request a quote

or write directly: email address