PHPSecurity Best Practices

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

HTML
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.

Never leave display_errors on in production
A production php.ini should set display_errors = Off and log_errors = On instead, sending error detail to a log file the operations team can read, while showing end users a generic error page with no internal detail at all.

Recommended production php.ini settings

HTML
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
<?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
<?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 0

A 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)

htmlspecialchars($value, ENT_QUOTES, 'UTF-8') on output, context-aware escaping, CSP as a second layer.

Cross-site request forgery (CSRF)

Per-session token via random_bytes(), validated with hash_equals(), plus SameSite cookies.

Weak password storage

password_hash() / password_verify() with PASSWORD_DEFAULT or PASSWORD_ARGON2ID.

Sensitive data exposure

sodium_crypto_secretbox for data you must decrypt later; HTTPS and Secure cookies in transit.

Credential stuffing / brute force

Rate-limiting and temporary lockouts on repeated failed logins.

Information leakage via errors

display_errors = Off in production, with errors logged server-side instead.

Excessive database privileges

Least-privilege application database accounts, no superuser access.

Known vulnerabilities in dependencies

composer audit run routinely, PHP and packages kept up to date.

  • 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 SameSite cookies.

  • 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.

Security work does not end when a feature ships
New vulnerabilities are discovered in existing software all the time, in PHP itself, in popular packages, and in application code that was fine when it was written. Treat the practices on this page as ongoing maintenance — patching, auditing, and periodically revisiting old assumptions — rather than a one-time checklist to clear before launch.
Tip
If you only take one habit from this entire section, make it this one: whenever you write a line of code that uses a value from outside your application, pause and ask where that value is about to go — a SQL query, an HTML page, a JavaScript context, a shell command — and use the safe API built for that specific destination. Nearly every vulnerability covered in this section is a version of that one question being skipped.