PHP Security in 2026: A Practical Checklist for Developers

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
- Stop concatenating SQL. Use prepared statements.
- Password hashing: password_hash and nothing else
- Session handling is where sites get quietly owned
- Output escaping, not input filtering
- File uploads: never trust the file
- Security headers you can set in five minutes
- Keep PHP patched and dependencies current
- Configuration details that quietly matter
- CSRF tokens on every state change
- What I would do first on an existing site
- FAQ
- Is PDO safer than mysqli?
- Do I still need CSRF tokens if I use SameSite cookies?
- How often should I update PHP and Composer packages?
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.

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 => falseso 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 BYor 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.

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 = Offin production,log_errors = On. Error messages leak file paths and query fragments.expose_php = Offso you are not advertising your PHP version in response headers.allow_url_fopenoff unless you need it. It is a common path for SSRF chains.- Disable dangerous functions like
exec,shell_exec,system, andpassthruif 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:
- Grep for concatenated queries and
md5orsha1on passwords. - Confirm the PHP version and upgrade it.
- Add the security headers.
- Turn off
display_errorsin production. - Add CSRF checks to forms.
- 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.


