PHP Security in 2026: 5 Real Fixes That Stop Common Attacks

Most PHP breaches in 2026 still come from the same basics: SQL built by string concatenation, weak password hashing, unescaped output, and unvalidated input. Fix those with prepared statements, passwordhash, output escaping, and strict validation, and you close the holes attackers actually use.
Part of the complete web development guide.
On this page
- 1. Stop building SQL by hand
- 2. Use password_hash, and stop inventing your own hashing
- 3. Harden your sessions
- 4. Treat every upload as hostile
- 5. Escape output for the context it lands in
- A few things that are not code
- FAQ
- Is prepared statements enough to stop all SQL injection?
- Do I need to switch from bcrypt to Argon2id?
- Can I just use a framework and skip all this?
Most PHP breaches I see on small business sites are not exotic. They come from the same handful of mistakes that have been around for two decades, still shipping in code that was written last week. Here are five fixes that actually matter in 2026, with the code to back them up.
1. Stop building SQL by hand
String concatenation into SQL is still the number one way people get owned. If user input touches a query string, you have a problem. Use prepared statements with bound parameters. PDO makes this painless.
<?php
$pdo = new PDO('mysql:host=localhost;dbname=shop;charset=utf8mb4', $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$stmt = $pdo->prepare('SELECT id, email FROM users WHERE email = ? AND active = 1');
$stmt->execute([$email]);
$user = $stmt->fetch();
?>
Two things people miss. First, set ATTR_EMULATE_PREPARES to false so MySQL does the real binding instead of PHP faking it. Second, parameters only work for values. You cannot bind a table name or a column name. If you are letting users pick a sort column, whitelist it against a fixed array. No exceptions.
<?php
$allowed = ['created_at', 'price', 'name'];
$sort = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'created_at';
$sql = "SELECT * FROM products ORDER BY $sort DESC LIMIT 50";
?>
2. Use password_hash, and stop inventing your own hashing
If I still find MD5 or SHA1 password hashes in a 2026 codebase, it is almost always legacy code nobody wanted to touch. Fix it. PHP’s password_hash() uses bcrypt by default, and you can move to Argon2id if your host supports it.
<?php
// Storing a new password
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);
// Verifying on login
if (password_verify($plainPassword, $storedHash)) {
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
$newHash = password_hash($plainPassword, PASSWORD_DEFAULT);
// save $newHash
}
// log the user in
}
?>
The password_needs_rehash() check is the part people skip. It lets you upgrade hashing strength over time without forcing everyone to reset their password. For existing MD5 users, verify the old hash once, then immediately rehash with password_hash() and store the new value.
3. Harden your sessions
Session hijacking is boring and effective. A stolen session cookie is a logged-in user, no password needed. Tighten the basics in your bootstrap file before any output.
<?php
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true, // HTTPS only
'httponly' => true, // no JavaScript access
'samesite' => 'Lax', // blocks most CSRF via cross-site POST
]);
session_start();
// Rotate the ID on login to stop fixation
session_regenerate_id(true);
?>
Set secure to true only if the site is actually on HTTPS. If you are still on HTTP in 2026, that is its own problem, and Let’s Encrypt costs nothing. Also regenerate the session ID after login and after any privilege change. Skipping that leaves you open to session fixation, where an attacker plants a known ID before you log in.
4. Treat every upload as hostile
File uploads are the fastest path from a web form to remote code execution. The classic disaster is trusting the file extension or the browser-supplied MIME type. Both are trivially forged. Check the actual contents, rename the file, and store it outside the web root.
<?php
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['photo']['tmp_name']);
if (!isset($allowed[$mime])) {
http_response_code(415);
exit('Unsupported file type.');
}
if ($_FILES['photo']['size'] > 5 * 1024 * 1024) {
exit('File too large.');
}
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$dest = __DIR__ . '/../storage/uploads/' . $name; // outside public root
if (!move_uploaded_file($_FILES['photo']['tmp_name'], $dest)) {
exit('Upload failed.');
}
?>
Note the finfo check reads the file’s real bytes, not what the client claims. Random filenames kill path traversal and overwrite attacks in one move. And keep the storage directory outside the document root so a .php file that somehow slipped through cannot be executed. If you must serve those files, do it through a script that checks permissions first.
5. Escape output for the context it lands in
XSS is not solved by one function. It depends on where the data goes. HTML body, HTML attribute, JavaScript, URL, CSS all have different rules. For plain HTML, htmlspecialchars() with the right flags covers you.
<?php
function e(?string $s): string {
return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<p>Welcome back, <?= e($user['name']) ?></p>
<input type="text" value="<?= e($user['name']) ?>">
Escape on output, not on input. Storing escaped data means you double-encode it later and break things. For anything going into a <script> block, use json_encode() with the appropriate flags instead of string concatenation. For URLs, rawurlencode(). If you are generating HTML with a template engine like Twig or Blade, auto-escaping is on by default, so do not disable it to “fix” a rendering bug.
A few things that are not code
- Keep PHP itself patched. Running an end-of-life version like 7.4 or 8.0 means no security fixes, period. Aim for 8.3 or newer.
- Turn off
display_errorsin production and log to a file instead. Stack traces in the browser hand attackers a map. - Set a Content Security Policy header. It will not fix bad code, but it makes XSS much harder to exploit.
- Rate limit login endpoints. Brute force is still how a lot of accounts get taken over.
None of this is glamorous work. It is the kind of thing that quietly keeps a site standing while everyone else gets hit. If you are maintaining a PHP app and want a second set of eyes on it, that is a big part of what I do with custom web development for clients around Commack and the rest of Long Island. A lot of the fixes above take an afternoon.
FAQ
Is prepared statements enough to stop all SQL injection?
For values, yes, as long as you are not emulating prepares. It does not cover identifiers like table or column names, so whitelist those separately.
Do I need to switch from bcrypt to Argon2id?
Not urgently. Bcrypt with a decent cost factor is still fine in 2026. If your host supports Argon2id, use it for new passwords and let password_needs_rehash() upgrade the rest over time.
Can I just use a framework and skip all this?
Frameworks handle a lot of it, but not everything. You still choose how to handle uploads, sessions, and output escaping in your own code. The framework gives you good tools, not immunity.


