Development Tips

PHP Security Basics Every Site Owner Should Know in 2026

By Ken Key Aug 21, 2024 6 min read Updated Oct 16, 2026

PHP Security Basics Every Site Owner Should Know in 2026
The short answer

Most PHP breaches come from a few avoidable mistakes: SQL injection from concatenated queries, unescaped output causing XSS, weak password hashing, and unvalidated file uploads. Use prepared statements with emulated prepares off, escape output with htmlspecialchars, hash with passwordhash, and keep PHP and dependencies patched.

Part of the complete web development guide.

On this page

Most PHP sites don’t get hacked because of some exotic zero-day. They get hacked because someone concatenated user input into a query in 2019 and nobody ever went back to fix it. The same handful of mistakes show up over and over, and all of them are preventable with a few lines of code.

Here’s what actually matters, in the order it tends to bite people.

SQL injection is still the number one problem

Every time I clean up a compromised PHP site, this is where it started. String concatenation into SQL is the bug. It has been the bug for twenty years.

// Do not do this
$email = $_POST['email'];
$result = $pdo->query("SELECT * FROM users WHERE email = '$email'");

One apostrophe in that field and the query breaks. A little more effort and someone reads your entire users table, or drops it.

Use prepared statements. With PDO it looks like this:

$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $_POST['email']]);
$user = $stmt->fetch();

Two things to check on any existing codebase. First, make sure emulated prepares are off, because that setting quietly re-introduces the old string interpolation under the hood:

$pdo = new PDO($dsn, $user, $pass, [
 PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
 PDO::ATTR_EMULATE_PREPARES => false,
 PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

Second, check for ORDER BY and LIMIT clauses built from request data. Prepared statements don’t work for identifiers, so people concatenate there and undo all their good work. Whitelist those values against a fixed array instead.

Escape output, not input

I see this backwards constantly. Developers run htmlspecialchars() on everything going into the database, then echo it straight out. That’s the wrong end.

Store the raw data. Escape at the moment you print it, based on where you’re printing.

<?php
// In HTML context
echo htmlspecialchars($comment, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>

If you’re inside a JavaScript block, HTML escaping is not enough. If you’re inside an HTML attribute, it’s different again. This is why templating engines exist. Twig and Blade escape by default, and that default has saved more sites than any security audit ever has.

If you’re working in raw PHP, the rule is simple: every echo of a variable that came from a user needs an escape function wrapped around it. No exceptions, no “but this one is safe.”

Password storage

If you find md5() or sha1() anywhere near a password field, stop and fix it before you do anything else. Those are fast hashes. Fast is exactly what you don’t want.

// Hashing on registration
$hash = password_hash($password, PASSWORD_DEFAULT);

// Verifying on login
if (password_verify($password, $storedHash)) {
 if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
 $newHash = password_hash($password, PASSWORD_DEFAULT);
 // save $newHash
 }
 // log them in
}

PASSWORD_DEFAULT currently maps to bcrypt, and the password_needs_rehash() check means you upgrade automatically when PHP changes the default. That’s the whole point of using the built-in API instead of rolling your own.

Sessions and cookies

Session handling is where a lot of otherwise decent code falls apart. Three settings to get right:

session_set_cookie_params([
 'lifetime' => 0,
 'path' => '/',
 'secure' => true, // HTTPS only
 'httponly' => true, // no JavaScript access
 'samesite' => 'Lax', // or 'Strict' if nothing breaks
]);
session_start();

Then regenerate the session ID on login. This is the fix for session fixation, where an attacker plants a known session ID before you log in and then rides it afterward.

if (password_verify($password, $user['password_hash'])) {
 session_regenerate_id(true);
 $_SESSION['user_id'] = $user['id'];
}

Also set an absolute timeout. A session that stays alive for six months is a session that will eventually be stolen from a shared laptop or a browser extension.

File uploads

Uploads are the most common path to full server compromise on cheap shared hosting. The failure mode is always the same: the code checks the file extension and trusts it.

Don’t trust the extension. Don’t trust $_FILES['file']['type'] either, since the client sends that and can send anything. Check the actual content:

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['upload']['tmp_name']);

$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];

if (!isset($allowed[$mime])) {
 throw new RuntimeException('Unsupported file type.');
}

$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
move_uploaded_file($_FILES['upload']['tmp_name'], '/var/uploads/' . $name);

Two more things. Store uploads outside the web root, or at minimum disable PHP execution in the upload directory. And never use the original filename. Users name files things like shell.php on purpose.

CSRF tokens on state-changing requests

Any form that changes data needs a token. Generate one per session, put it in the form, verify it on POST.

// Generating
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));

// In the form
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">

// On submit
if (!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) {
 http_response_code(403);
 exit('Invalid request.');
}

hash_equals() rather than ==, because the comparison should be constant-time. It’s a small thing but it costs nothing to do right.

Dependencies and the PHP version itself

Run composer audit on a schedule. Not once when you set the project up, but monthly, because new advisories land on old packages all the time and nobody emails you about it.

Same story with PHP itself. If the site is still on 7.4, you’re running a version that stopped getting security patches years ago. Moving to a supported release is unglamorous work that fixes real vulnerabilities, and it’s usually the single biggest security improvement available to a small business site. If you’re not in a position to do that migration yourself, that’s the kind of thing custom web development work usually starts with.

Things that are not security

A few practices that feel like security but aren’t:

  • Hiding the PHP version in headers. Obscurity, not defense. The version is usually discoverable anyway.
  • Blocking by user agent string. Trivially spoofed.
  • Renaming wp-admin or admin. Slows down bots that scan for the default path, does nothing against a targeted attack.
  • A WAF as your only control. Useful as a layer, not as a substitute for parameterized queries.

None of these hurt. But if they’re the whole plan, the plan doesn’t work.

What to actually do this week

If you own a PHP site and want a realistic starting point:

  • Grep the codebase for $_GET, $_POST, and $_REQUEST inside query strings. Fix those first.
  • Check that display_errors is off in production. Stack traces in the browser hand an attacker your file paths and framework versions.
  • Confirm every page that handles login or payment is on HTTPS, with HSTS enabled.
  • Run composer audit and read the output.
  • Turn on automatic backups, and actually test restoring one. A backup you’ve never restored is a guess.

None of this is exciting. That’s kind of the point. The sites that stay up are the boring ones where someone did the unglamorous work and then left it alone.

FAQ

Is PHP less secure than other languages?

No. PHP has solid built-in tools for hashing, database access, and random number generation, and the modern frameworks handle most of this for you. The problem is that PHP is easy to write badly and runs a huge share of the web, so the bad code is more visible. The same injection flaws exist in every language.

How often should I update PHP and my dependencies?

Security patches as soon as they’re available, ideally within a few days. Full version upgrades once a year at minimum, and sooner if you’re on a release that’s reached end of life. Put it on a calendar rather than waiting for something to break.

Can I secure a site without a developer?

You can cover a lot of it. Managed hosting with automatic updates, HTTPS, strong unique passwords, and two-factor authentication on the admin account close the majority of common attack paths. Once you’re dealing with custom code, payment handling, or user accounts, it’s worth having someone read the actual source.

Have a project like this?

I do custom web development for Long Island businesses. If you need this done right, let’s talk.