PHP Security in 2026: 10 Things I Check Before a Site Goes Live

Before a PHP site goes live I check ten things: prepared statements with emulation off, output escaping, session hardening, file upload validation, dependency updates, error display off, CSRF tokens, secure cookies, and locked down config files. Most breaches come from stale code, not clever attacks.
Part of the complete web development guide.
On this page
- 1. Stop concatenating SQL. Use prepared statements everywhere
- 2. Escape on output, not on input
- 3. CSP is the backstop for everything you missed
- 4. Stop rolling your own auth
- 5. CSRF tokens on every state-changing request
- 6. File uploads: validate the content, not the filename
- 7. Keep dependencies patched, and actually read the advisories
- 8. Turn off display_errors in production and log instead
- 9. Lock down what the web server can reach
- 10. Log enough to know when something’s wrong
- Where to start if this is all new
- FAQ
- Is PHP still secure enough to build on in 2026?
- What’s the single most common vulnerability I’ll find in an older PHP site?
- Do I need a security audit if I’m using a framework like Laravel or Symfony?
Most PHP sites don’t get breached by geniuses. They get breached by a stale dependency, a missing exit after a redirect, or a file upload handler that trusts the browser. I’ve cleaned up enough of these to know the pattern, so here’s the list I actually run through before any site I build goes live.
None of this is exotic. It’s the boring stuff that stops 95% of the automated attacks hitting your server every day. If you’re running an older codebase, start at number one and work down.

1. Stop concatenating SQL. Use prepared statements everywhere
I still see string-built queries in 2026. PDO has been able to bind parameters since PHP 5.1. There’s no excuse.
// Wrong. One apostrophe in a name and you're done.
$sql = "SELECT * FROM users WHERE email = '$email'";
// Right.
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
Two details people miss. First, set PDO to throw exceptions so failures aren’t silent:
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
That last line matters. With emulation on, PDO does its own escaping and you lose the real server-side protection. Turn it off.
Second, parameters can’t bind identifiers. You can’t do ORDER BY :column. If you’re letting users sort a table, whitelist the column names explicitly:
$allowed = ['created_at', 'name', 'price'];
$sort = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'created_at';

2. Escape on output, not on input
The classic mistake is running htmlspecialchars() on everything at the top of the request and then storing mangled data in the database. Escape at the point of rendering, and escape for the right context.
<?php // In HTML context
echo htmlspecialchars($comment, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>
<?php // In a URL parameter
echo urlencode($value); ?>
<?php // In a JavaScript variable
echo json_encode($value, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?>
If you’re using a template engine like Twig or Blade, autoescaping is on by default and you should almost never reach for the raw filter. When you do, make sure it’s for content you fully control.
3. CSP is the backstop for everything you missed
Content Security Policy used to be painful. In 2026 it’s not, and it’s the single best defense against XSS that slips through. Start in report-only mode so you don’t break your own site:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; report-uri /csp-report
Watch the reports for a week, fix what’s actually loading, then flip the header to the enforcing version. The base-uri 'none' and object-src 'none' directives cost you nothing and close off two whole attack classes.
While you’re in the headers, add these:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
4. Stop rolling your own auth
Password hashing is solved. Use password_hash() with the default algorithm (Argon2id where available, bcrypt otherwise) and password_verify(). Do not write your own salt logic. Do not use MD5 or SHA-1 for anything password-related, and yes, I’ve seen both this year.
$hash = password_hash($password, PASSWORD_DEFAULT);
if (password_verify($input, $storedHash)) {
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
$newHash = password_hash($input, PASSWORD_DEFAULT);
// update the row
}
}
That rehash check is the part people skip. It lets you upgrade hashing parameters over time without forcing a password reset on everyone.
For sessions, regenerate the ID on login and set the cookie flags properly:
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
session_regenerate_id(true);
5. CSRF tokens on every state-changing request
GET requests should never change data. Everything else needs a token tied to the session. PHP’s built-in random source is fine for this.
// Generate once per session
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
// Verify on POST
if (!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) {
http_response_code(403);
exit('Invalid request');
}
hash_equals() is constant-time. A plain == comparison leaks timing information. It’s a small thing, but it’s free to do right.
6. File uploads: validate the content, not the filename
Never trust $_FILES['file']['type']. That value comes from the client. Check the actual bytes.
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['upload']['tmp_name']);
$allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($mime, $allowed, true)) {
exit('Unsupported file type');
}
// Store outside the webroot, serve through a PHP script
$name = bin2hex(random_bytes(16)) . '.bin';
move_uploaded_file($_FILES['upload']['tmp_name'], '/var/uploads/' . $name);
Store uploads outside the document root and serve them through a handler that sets the right Content-Type. That way even a mislabeled file can’t be executed. Also set upload_max_filesize and post_max_size to something sane in your php.ini, because an unbounded upload is its own denial of service.
7. Keep dependencies patched, and actually read the advisories
Composer makes this easy. Run it on a schedule, not once a year.
composer outdated --direct
composer audit
composer audit checks your lock file against the public vulnerability database and exits non-zero if it finds something. Wire that into CI and let it fail the build. If you’re on a framework, follow its security release channel. Laravel and Symfony both publish security patches outside their normal release cycle, and those are the ones you can’t wait on.
One more thing: pin your PHP version in production and keep it current. Running on an end-of-life branch means no security fixes at all, no matter how clean your code is.
8. Turn off display_errors in production and log instead
Stack traces are a gift to anyone probing your site. They reveal file paths, framework versions, and sometimes database credentials.
; php.ini for production
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/error.log
expose_php = Off
Set up a handler so users get a clean error page and you get the full detail in your logs or your error tracker. expose_php = Off stops PHP from advertising its version in every response header.
9. Lock down what the web server can reach
Your PHP process shouldn’t be able to write to your application code. If an attacker gets code execution, the first thing they’ll try is dropping a webshell into a writable directory.
- Run PHP as a dedicated user, not as the web server’s default user
- Set the document root to a
public/directory and keep everything else above it - Make application files read-only to the PHP user
- Disable dangerous functions you don’t use:
exec,shell_exec,system,passthru,proc_open - Block direct access to
.env,composer.json, and anything invendor/
That last one bites people constantly. A misconfigured server will happily serve your .env file with your database password in it. Test it yourself: request /.env and see what comes back.
10. Log enough to know when something’s wrong
You can’t respond to what you can’t see. Log authentication failures, password resets, permission changes, and anything that touches money. Include the user ID, the IP, and a timestamp.
The signals worth alerting on are simple: a spike in 403s from one IP, repeated failed logins on the same account, or an admin action from an unfamiliar location. You don’t need a SIEM. A daily digest email of the weird stuff is enough for most sites.
And back up your database and uploads somewhere the web server can’t reach. Ransomware crews target PHP sites specifically because so many of them have no offline backup.
Where to start if this is all new
If you inherited a codebase and don’t know where to begin, do these three things this week: turn off display_errors, run composer audit, and grep for string-concatenated SQL. That covers the majority of what actually gets exploited.
If you’d rather have someone go through it properly, that’s the kind of work I do as a custom web development shop here on Long Island. A security review is usually a day or two, and it’s cheaper than the cleanup.
FAQ
Is PHP still secure enough to build on in 2026?
Yes. The language itself has solid primitives for hashing, random values, and database access. Almost every PHP breach traces back to how the code was written or how the server was configured, not to PHP itself. Keep it patched and follow the basics above and it’s as safe as any other stack.
What’s the single most common vulnerability I’ll find in an older PHP site?
SQL injection from string-built queries, followed closely by stored XSS from unescaped output. Both are fixed with the same two habits: prepared statements for anything touching the database, and htmlspecialchars() at render time. Neither requires a rewrite, just discipline.
Do I need a security audit if I’m using a framework like Laravel or Symfony?
The framework handles a lot for you, but it can’t protect you from your own application code. Custom queries, custom file handling, and misconfigured servers are where framework-based sites still get hit. The audit should focus on your code, not the framework’s.


