Development Tips

PHP Security in 2026: A Practical Checklist for Developers

By Ken Key May 15, 2024 6 min read Updated Oct 16, 2026

PHP Security in 2026: A Practical Checklist for Developers
The short answer

The core PHP security checklist for 2026: use prepared statements with emulation off, escape all output, set HttpOnly and Secure session cookies, validate uploads by content not extension, keep PHP and dependencies patched, and never trust user input. Most breaches trace back to skipping one of these.

Part of the complete web development guide.

On this page

Most PHP exploits I see in the wild are not exotic. They are the same handful of mistakes made over and over: a query built with string concatenation, a session cookie without the right flags, an upload handler that trusts the file extension. The fixes are not hard, but they have to be applied consistently. Here is what I actually check on a PHP codebase in 2026.

I have been writing PHP for a long time and doing custom web development for clients around Commack and the rest of Long Island, and the pattern holds whether the site is a small business brochure site or a booking system handling real money.

PHP Security in 2026: A Practical Checklist for Developers

Stop concatenating SQL. Use prepared statements.

SQL injection is still the number one thing that takes sites down, mostly because people copy old tutorials. If you are on PDO, this is the whole fix:

// Wrong
$sql = "SELECT * FROM users WHERE email = '" . $_POST['email'] . "'";

// Right
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = :email');
$stmt->execute(['email' => $_POST['email']]);
$user = $stmt->fetch();

A few rules that matter more than people think:

  • Set PDO::ATTR_EMULATE_PREPARES => false so the database does the binding, not PDO’s string emulation.
  • Set PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION. Silent failures hide bugs and sometimes leak nothing useful while breaking everything.
  • Never put user input in an ORDER BY or a column name through a placeholder. Those cannot be parameterized. Use a whitelist array instead.
$allowed = ['created_at', 'name', 'price'];
$order = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'created_at';
$sql = "SELECT * FROM products ORDER BY $order DESC";

That last one catches people constantly. Placeholders only work for values, not identifiers.

PHP Security in 2026: A Practical Checklist for Developers: Password hashing: password_hash and nothing else

Password hashing: password_hash and nothing else

Stop using md5, sha1, or even a single round of sha256. They are built to be fast, which is exactly what you do not want. Use password_hash(), which defaults to bcrypt, and check with password_verify().

$hash = password_hash($password, PASSWORD_DEFAULT);

if (password_verify($input, $storedHash)) {
 // good
}

if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
 $new = password_hash($input, PASSWORD_DEFAULT);
 // save $new
}

Argon2id is available on any modern PHP build and is a reasonable choice if your host supports it, but bcrypt via PASSWORD_DEFAULT is fine and will keep working when PHP bumps the default. The rehash check is the part people skip; add it to your login flow so old hashes upgrade quietly over time.

Session handling is where sites get quietly owned

Default PHP sessions are weak out of the box. Harden them in your bootstrap file, before session_start():

session_set_cookie_params([
 'lifetime' => 0,
 'path' => '/',
 'domain' => '',
 'secure' => true, // HTTPS only
 'httponly' => true, // no JS access
 'samesite' => 'Lax', // or 'Strict' if your flows allow it
]);
session_start();

Then regenerate the session ID on login and on privilege change:

session_regenerate_id(true);

That one line stops session fixation, where an attacker plants a known session ID and waits for you to log in. Also set a reasonable idle timeout and actually destroy the session on logout with session_destroy() plus setcookie() to clear the cookie.

Output escaping, not input filtering

Beginners sanitize input and forget output. That is backwards. Escape at the point of output, based on context. For HTML:

echo htmlspecialchars($comment, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

For JSON, use json_encode() with the right flags. For URLs, use rawurlencode(). For HTML attributes, escape quotes too. The rule is simple: the escaping function has to match where the data is going, and it has to happen on the way out.

File uploads: never trust the file

An upload form is an open door if you let it be. Check all of this:

  • Verify the MIME type with finfo, not $_FILES['type'], which the browser controls.
  • Generate your own filename. Do not keep the user’s.
  • Store uploads outside the web root, or at minimum block script execution in the upload directory.
  • Cap the size and check is_uploaded_file() before moving anything.
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['doc']['tmp_name']);
$allowed = ['image/jpeg' => 'jpg', 'application/pdf' => 'pdf'];

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

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

If your uploads live under the web root (shared hosting often forces this), drop an .htaccess in that folder that turns off PHP execution. It is a cheap insurance policy.

Security headers you can set in five minutes

These go in your web server config or in PHP via header(). They do not fix bugs, but they cut off whole classes of attack.

header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: strict-origin-when-cross-origin');
header("Content-Security-Policy: default-src 'self'; script-src 'self'");
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');

Start the Content-Security-Policy in report-only mode if you have inline scripts or third-party widgets. It will break things if you turn it on cold, and you want to see the violations first.

Keep PHP patched and dependencies current

PHP 8.1 reached end of life at the end of 2025, so if you are still running it, you are shipping unpatched code. As of 2026, 8.2 and up are the supported lines, with 8.4 being the current stable release. Check your version:

php -v

Then run Composer’s audit on every deploy:

composer audit
composer outdated --direct

Composer will now block installs of packages with known advisories by default, which is a real improvement, but it only helps if you actually run the update. Put it in CI so it is not a manual step someone forgets.

Configuration details that quietly matter

  • display_errors = Off in production, log_errors = On. Error messages leak file paths and query fragments.
  • expose_php = Off so you are not advertising your PHP version in response headers.
  • allow_url_fopen off unless you need it. It is a common path for SSRF chains.
  • Disable dangerous functions like exec, shell_exec, system, and passthru if your app does not call them.

CSRF tokens on every state change

If a form or an AJAX endpoint changes data, it needs a token tied to the session. Generate one per session, store it server-side, and compare with hash_equals() to avoid timing leaks.

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

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

SameSite cookies help here, but they are not a substitute. Browsers change defaults, and older clients do not honor them consistently.

What I would do first on an existing site

If you inherited a PHP codebase and want the highest return for your time, in order:

  1. Grep for concatenated queries and md5 or sha1 on passwords.
  2. Confirm the PHP version and upgrade it.
  3. Add the security headers.
  4. Turn off display_errors in production.
  5. Add CSRF checks to forms.
  6. Run composer audit.

None of that is glamorous. It is also the difference between a site that survives a scanner sweep and one that ends up on a blacklist.

FAQ

Is PDO safer than mysqli?

Neither is inherently safer. Both support prepared statements, and both are unsafe if you concatenate strings. PDO is more flexible across database engines, and its named placeholders are easier to read, which is why I default to it.

Do I still need CSRF tokens if I use SameSite cookies?

Yes. SameSite=Lax handles most cross-site form posts, but it does not cover every browser behavior or subdomain setup. Tokens are cheap and they close the gap.

How often should I update PHP and Composer packages?

Patch releases within your minor version, monthly at minimum. Plan a minor version upgrade every year, and do not let your version fall out of security support. Once a branch hits end of life, you are on your own for fixes.

Have a project like this?

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