PHP

PHP Security in 2026: What Actually Matters (and What’s Overkill)

By Ken Key May 14, 2026 5 min read Updated Oct 16, 2026

PHP Security in 2026: What Actually Matters (and What’s Overkill)
The short answer

The stuff that actually stops breaches in PHP is boring and old: prepared statements for every query, passwordhash and passwordverify for credentials, output escaping to kill XSS, and current dependencies. Everything else is hardening. Get those four right first, then worry about the rest.

Part of the complete web development guide.

On this page

Most PHP security advice online is a list of twenty things you should do, with no sense of which ones actually stop attacks. I’ve spent the last decade reviewing PHP codebases, many of them right here on Long Island, and the same handful of mistakes show up over and over. Here’s what I’d fix first, in order, if I inherited your codebase tomorrow.

1. Stop concatenating SQL. Use prepared statements, always.

SQL injection is not a 2008 problem. It’s still the single most common serious vulnerability I find. The fix is not “escape the input.” The fix is parameterized queries, and it has been for years.

// Wrong. Don't do this, ever.
$sql = "SELECT * FROM users WHERE email = '$email'";
$result = $pdo->query($sql);

// Right. The value never touches the SQL string.
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

Same deal with mysqli, though I’d reach for PDO in new code. The one place people get this wrong is dynamic column names or ORDER BY clauses, which you cannot parameterize. There, use an allowlist:

$allowed = ['created_at', 'name', 'email'];
$sort = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'created_at';
$dir = ($_GET['dir'] ?? 'asc') === 'desc' ? 'DESC' : 'ASC';

$stmt = $pdo->query("SELECT * FROM users ORDER BY {$sort} {$dir}");

2. Password hashing: use the built-in API, not MD5

If you’re still storing MD5 or SHA1 hashes, that’s a breach waiting to happen. PHP has had password_hash() since 5.5, and in 2026 there is no reason to do anything else.

// Store
$hash = password_hash($password, PASSWORD_DEFAULT);

// Verify
if (password_verify($password, $storedHash)) {
 // check if the algorithm or cost changed, rehash if so
 if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
 $newHash = password_hash($password, PASSWORD_DEFAULT);
 // save $newHash
 }
}

PASSWORD_DEFAULT currently maps to bcrypt. Argon2id (PASSWORD_ARGON2ID) is available if your build supports it and you want stronger memory-hard hashing. Either is fine. What is not fine is a homemade salt-and-sha256 scheme.

3. Session handling and cookies

Sessions are where authentication actually lives, so they deserve real attention. A few things I set in every project:

  • session.cookie_httponly = 1 so JavaScript can’t read the session cookie.
  • session.cookie_secure = 1 so it only travels over HTTPS.
  • session.cookie_samesite = "Lax" (or Strict) to blunt CSRF.
  • Regenerate the session ID on login to prevent fixation.
session_set_cookie_params([
 'lifetime' => 0,
 'path' => '/',
 'secure' => true,
 'httponly' => true,
 'samesite' => 'Lax',
]);
session_start();

if ($loginSuccessful) {
 session_regenerate_id(true);
 $_SESSION['user_id'] = $user['id'];
}

For CSRF on state-changing forms, a per-session token checked with hash_equals() is still the simplest thing that works. Don’t roll your own crypto for tokens. Use random_bytes().

4. Output escaping is a separate problem from input validation

People conflate these. Validating input is about data integrity. Escaping output is about preventing XSS. You need both, and the escaping has to happen at the point of output, in the right context.

<!-- In a template, escape HTML context -->
<p><?= htmlspecialchars($comment, ENT_QUOTES, 'UTF-8') ?></p>

<!-- Inside a JS string, JSON-encode it -->
<script>
 const data = <?= json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP) ?>;
</script>

If you’re using a template engine like Twig or Blade, autoescaping is on by default. Don’t turn it off to “fix” a rendering issue. Fix the actual issue.

5. File uploads and path handling

Uploads are a favorite attack vector. Three rules:

  • Never trust the file extension or the Content-Type the browser sends. Check the real MIME type with finfo.
  • Store uploads outside the web root, or at minimum forbid execution in the upload directory.
  • Generate your own filename. Never use the client-supplied one.
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['upload']['tmp_name']);

$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
if (!array_key_exists($mime, $allowed)) {
 throw new RuntimeException('Unsupported file type');
}

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

For anything user-facing that builds paths from input, run it through basename() and reject anything containing ... Directory traversal bugs are boring and completely preventable.

6. Dependencies are your attack surface

A lot of real-world PHP compromises don’t come from your code. They come from a package you pulled in two years ago. Run composer audit as part of your CI, and keep it in your routine.

composer audit
composer outdated --direct

Pin your versions in composer.lock, commit that lock file, and update deliberately. “It works, don’t touch it” is how you end up running a library with a known CVE for eighteen months.

7. The boring stuff that saves you

None of this is glamorous, but it’s what separates a site that survives a bad week from one that doesn’t:

  • Set display_errors = Off in production and log to a file instead. Stack traces leak paths and config.
  • Force HTTPS and add HSTS. Most hosts make this a checkbox now.
  • Keep PHP itself patched. If you’re on a version that’s past end-of-life, that’s your biggest risk by a wide margin.
  • Rate-limit login and password reset endpoints. Brute force is still effective against lazy targets.

If you’re maintaining a growing application and this list feels like a lot to retrofit, that’s normal. It usually makes sense to bring in someone who does this regularly, whether that’s custom web development work or a focused security review of what you already have. The goal isn’t perfection. It’s closing the holes that attackers actually use.

FAQ

Is PHP still secure enough to build on in 2026?

Yes. Modern PHP (8.x and beyond) is a mature, actively maintained language with solid built-in security primitives. The vulnerabilities come from how code is written, not the language itself.

What’s the single most important thing to fix first?

SQL injection. If you have any query building SQL from user input via string concatenation, fix that before anything else on this list.

Do I need a framework for security?

No, but frameworks like Laravel and Symfony give you prepared queries, CSRF protection, and hashing by default, which removes a lot of the chances to get it wrong. If you’re writing raw PHP, you have to be deliberate about all of it.

Have a project like this?

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