PHP Security Best Practices
The rest of this section walked through specific attacks and their specific fixes — SQL injection, XSS, CSRF, password hashing, encryption. This page pulls back to the operational habits that sit around all of that code: how the server is configured, how credentials and dependencies are managed, and how an application behaves when something does eventually go wrong. Getting the code right and getting the environment right are both necessary; a perfectly written login form is still exposed if the server around it leaks stack traces or runs on an ancient, unpatched PHP version.
Keep PHP and dependencies updated
Every PHP version eventually reaches end of life and stops receiving security patches, and the same is true for every Composer package a project depends on. Vulnerabilities discovered in old versions are public record, documented in changelogs and CVE databases, which means an attacker targeting a known-outdated stack does not need to find a new bug — they just need to check your version number and look up what is already known to be broken in it.
Checking installed dependencies for known vulnerabilities
composer audit
composer audit checks the versions locked in composer.lock against a database of known security advisories and reports any that match. Running it in CI on every build catches a newly disclosed vulnerability in a dependency you already use, even if you have not touched that code recently.
Disable display_errors in production
PHP's display_errors setting is genuinely useful in local development — it prints warnings, notices, and fatal errors, including file paths and stack traces, directly onto the page. In production, that same output becomes a gift to an attacker: file paths reveal your directory structure, stack traces can expose database queries or credentials embedded in error messages, and any of it confirms which framework and version you are running.
Recommended production php.ini settings
display_errors = Off log_errors = On error_log = /var/log/php/error.log expose_php = Off
Least-privilege database accounts
The database account a web application connects with should only be able to do what that application actually needs — typically SELECT, INSERT, UPDATE, and DELETE on its own tables. It should not have DROP, ALTER, CREATE USER, or superuser privileges. If an application-level vulnerability (like a SQL injection bug that slips through) is ever exploited, a least-privilege account limits the blast radius to reading or modifying rows, rather than letting an attacker drop tables, create new database users, or read data from unrelated schemas on the same server.
HTTPS everywhere, with Secure cookies
Every page of an application should be served over HTTPS, not just the login form — session cookies, CSRF tokens, and any other sensitive value travel over the same connection as everything else, and a single page loaded over plain HTTP is enough for a network-level attacker to intercept a session cookie. Pairing this with the Secure cookie flag makes the browser refuse to send the cookie over an insecure connection at all, so a cookie cannot leak even if a stray link somewhere on the site still points to http://.
Cookie flags for a production session
<?php
session_set_cookie_params([
'secure' => true, // only sent over HTTPS
'httponly' => true, // not readable from JavaScript
'samesite' => 'Lax', // reduces CSRF exposure
]);
session_start();Rate-limit login attempts
password_hash() makes each individual guess computationally expensive, but it does not stop an attacker from submitting a moderate volume of common passwords against many accounts, a technique known as credential stuffing when the passwords come from a previous, unrelated breach. Rate-limiting login attempts — by account, by IP address, or both — and introducing a delay or a temporary lockout after a handful of failures closes that gap.
A simple attempt counter stored alongside the user record
<?php
if ($user['failed_attempts'] >= 5 && time() < $user['locked_until']) {
http_response_code(429);
exit('Too many attempts. Try again later.');
}
if (!password_verify($submittedPassword, $user['password_hash'])) {
$newCount = $user['failed_attempts'] + 1;
$lockUntil = $newCount >= 5 ? time() + 900 : null; // 15 minute lockout
// persist $newCount and $lockUntil for this user
exit('Invalid credentials.');
}
// success — reset failed_attempts to 0A CAPTCHA after several failed attempts, or a short exponential backoff, achieves a similar effect without permanently locking out a legitimate user who mistyped their password a few times.
Dependency scanning as a routine habit
Beyond running composer audit occasionally, treat it as a step that runs automatically — in a CI pipeline on every pull request, or on a scheduled job — so a newly disclosed vulnerability in an existing dependency surfaces without anyone having to remember to check manually. The same principle applies to any frontend dependencies managed with npm, which have their own equivalent auditing tools.
Summary: threat and mitigation
Threat | Primary mitigation |
|---|---|
SQL injection | Prepared statements with bound parameters (PDO / mysqli). |
Cross-site scripting (XSS) |
|
Cross-site request forgery (CSRF) | Per-session token via |
Weak password storage |
|
Sensitive data exposure |
|
Credential stuffing / brute force | Rate-limiting and temporary lockouts on repeated failed logins. |
Information leakage via errors |
|
Excessive database privileges | Least-privilege application database accounts, no superuser access. |
Known vulnerabilities in dependencies |
|
Treat every value from
$_GET,$_POST,$_COOKIE, headers, and file uploads as untrusted until validated and correctly escaped for its destination.Layer defenses — prepared statements plus least-privilege database accounts, output escaping plus CSP, CSRF tokens plus
SameSitecookies.Keep PHP, frameworks, and Composer packages patched, and check them routinely rather than only after an incident.
Fail safely: generic error messages for users, full detail only in server-side logs.