Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
PHPResource guide

PHP security checklist: passwords, SQL injection, XSS and sessions

A practical security checklist for plain PHP and legacy projects: password_hash(), prepared statements, output escaping, session settings, CSRF tokens, error handling and dependency audits, with code for PHP 8.2 to 8.5.

Cloudsoft Zone

7 min read

Rusty chain and brass padlock securing a metal gate next to a chain-link fence
Photograph: ImagePerson / CC BY-SA 4.0, via Wikimedia Commons
In this article

Most security incidents in PHP applications do not come from exotic attacks. They come from a short list of familiar mistakes: passwords stored with a fast hash, SQL built by joining strings, user input echoed into HTML, session cookies without the right flags, and servers running a PHP version that no longer receives fixes.

The PHP language and its standard library already include the tools to avoid all of these. This checklist collects them in one place, with short code examples that run on every PHP version supported in October 2026 (8.2 to 8.5). Frameworks such as Laravel and Symfony wrap many of these functions for you; the checklist is still useful for reviewing plain PHP code, legacy projects, small scripts and your framework configuration.

Each section ends with checkbox-style items you can copy into a code review template.

1. Run a supported PHP version and patch it

The php.net supported-versions page lists 8.2, 8.3, 8.4 and 8.5 as supported today. PHP 8.2 only receives security fixes until 31 December 2026; 8.3 until 31 December 2027; 8.4 until 31 December 2028; and 8.5 until 31 December 2029. The latest releases at the time of writing (8.5.11, 8.4.26, 8.3.35 and 8.2.34, all published on 24 September 2026) were security releases.

  • Every server, container image and CI runner uses a supported branch.
  • Patch releases are applied within days of publication, not months.
  • Projects on 8.2 have a dated upgrade plan before the end of 2026.

2. Hash passwords with password_hash()

Never store passwords in plain text, and never use general-purpose hashes such as MD5 or SHA-256 for them. PHP's password API generates a random salt, stores the algorithm and cost inside the hash, and verifies in constant time:

PHP
<?php
// Registration
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);
// Store $hash in a VARCHAR(255) column

// Login
if (!password_verify($plainPassword, $storedHash)) {
    throw new RuntimeException('Invalid credentials.');
}

// Upgrade old hashes transparently after a successful login
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
    $newHash = password_hash($plainPassword, PASSWORD_DEFAULT);
    // UPDATE users SET password_hash = :hash WHERE id = :id
}

Facts from the PHP manual worth knowing:

  • PASSWORD_DEFAULT currently uses bcrypt, and the constant is designed to change as stronger algorithms are added. That is why the manual recommends a column that can grow beyond 60 characters; 255 is its suggestion.
  • Since PHP 8.4, the default bcrypt cost is 12 instead of 10. Hashes created with cost 10 on older versions are reported by password_needs_rehash(), so the snippet above upgrades them as users log in.
  • bcrypt truncates passwords to 72 bytes. If you allow very long passphrases, consider PASSWORD_ARGON2ID, which is available when PHP is compiled with Argon2 support.
  • Do not pass your own salt. Since PHP 8.0, a supplied salt is ignored.

Checklist:

  • Passwords are hashed with password_hash() and checked with password_verify().
  • The hash column is at least 255 characters wide.
  • password_needs_rehash() runs after each successful login.

3. Use prepared statements for every query

SQL injection happens when user input becomes part of the SQL text. Prepared statements send the query and the values separately, so input is always treated as data. With PDO:

PHP
<?php
$pdo = new PDO($dsn, $dbUser, $dbPassword, [
    PDO::ATTR_EMULATE_PREPARES => false,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$stmt = $pdo->prepare('SELECT id, email FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();

Since PHP 8.0, PDO's default error mode is PDO::ERRMODE_EXCEPTION, so failed queries throw instead of failing silently. Disabling emulated prepares asks the driver to use native prepared statements where it supports them.

Placeholders work for values only. Table names, column names and sort directions cannot be bound, so check them against a fixed allowlist:

PHP
<?php
$allowedSorts = ['created_at' => 'created_at', 'name' => 'name'];
$column = $allowedSorts[$_GET['sort'] ?? ''] ?? 'created_at';
$direction = ($_GET['dir'] ?? '') === 'asc' ? 'ASC' : 'DESC';

$stmt = $pdo->prepare("SELECT id, name FROM products ORDER BY $column $direction LIMIT :limit");
$stmt->bindValue('limit', 20, PDO::PARAM_INT);
$stmt->execute();

Checklist:

  • No query concatenates or interpolates request data.
  • Identifiers and sort orders come from an allowlist.
  • The database user has only the privileges the application needs.
HTML head and body markup with syntax highlighting in a dark code editor on a monitor
Every dynamic value printed into HTML markup needs escaping for its context to prevent cross-site scripting.

4. Escape output for the context it goes into

Cross-site scripting (XSS) happens when user-controlled text is rendered as HTML or JavaScript. In plain PHP templates, escape every value when you print it:

PHP
<?php
function e(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}
?>
<p>Hello, <?= e($user['display_name']) ?></p>
<input type="text" name="city" value="<?= e($city) ?>">

Since PHP 8.1, htmlspecialchars() escapes both single and double quotes by default (ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401), but passing the flags and the encoding explicitly makes the intent obvious to reviewers. HTML escaping is only correct for HTML text and quoted attributes. For data passed to JavaScript, use json_encode() with the JSON_HEX_TAG, JSON_HEX_AMP, JSON_HEX_APOS and JSON_HEX_QUOT flags; for URLs, encode parameters with rawurlencode() and validate the scheme of any user-supplied link.

Checklist:

  • Every dynamic value in a template is escaped, or the template engine auto-escapes.
  • User-supplied URLs are limited to https: (and http: if needed).
  • A Content Security Policy header limits the damage if something slips through.

5. Configure sessions securely

The PHP manual's "Securing Session INI Settings" page lists the settings that matter. Set them in php.ini or pass them to session_start():

PHP
<?php
session_start([
    'use_strict_mode' => 1,     // reject session IDs PHP did not create
    'use_only_cookies' => 1,    // never accept IDs from URLs
    'cookie_httponly' => 1,     // JavaScript cannot read the cookie
    'cookie_secure' => 1,       // send only over HTTPS
    'cookie_samesite' => 'Lax', // CSRF mitigation
    'cookie_lifetime' => 0,     // expire when the browser closes
]);

// After a successful login or privilege change:
session_regenerate_id(true);

The manual describes session.use_strict_mode as mandatory for secure sessions and notes that it is disabled by default, so do not rely on defaults. Regenerating the session ID on login defeats session fixation, where an attacker plants a known session ID before the victim signs in. The manual also advises managing session lifetimes with your own timestamps rather than relying on garbage collection settings.

Checklist:

  • Strict mode, HttpOnly, Secure and SameSite are enabled.
  • The session ID is regenerated on login, logout and privilege changes.
  • Session IDs never appear in URLs or HTML.

6. Protect state-changing requests from CSRF

SameSite cookies reduce cross-site request forgery but do not replace tokens. Generate a random token per session, include it in every form, and compare it in constant time:

PHP
<?php
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

// In the form:
// <input type="hidden" name="csrf_token" value="<?= e($_SESSION['csrf_token']) ?>">

// When handling POST:
$sent = $_POST['csrf_token'] ?? '';
if (!is_string($sent) || !hash_equals($_SESSION['csrf_token'], $sent)) {
    http_response_code(403);
    exit('Invalid request.');
}

random_bytes() uses a cryptographically secure source, and hash_equals() avoids timing differences that a plain === comparison can leak. Use the same pair of functions for password-reset tokens and API keys.

7. Handle errors and secrets carefully

  • Set display_errors=Off and log_errors=On in production, so stack traces never reach users.
  • Mark function parameters that carry secrets with #[\SensitiveParameter], available since PHP 8.2; their values are then hidden in stack traces. PHP's own password_hash() uses it on the password parameter.
  • Keep credentials in environment variables or a secrets manager, never in the repository.
  • Do not use unserialize() on data a user can control. Use json_decode() instead.
  • Avoid shell commands where a PHP function exists; when you must run one, escape every argument with escapeshellarg(). The backtick operator is deprecated as of PHP 8.5.
Transparent padlock showing its pin mechanism, with two keys lying below it
Secrets such as tokens and passwords belong in environment variables or a secrets manager, never in code.

8. Audit your dependencies

Your Composer dependencies are part of your attack surface. Run composer audit in CI so that builds fail when an installed package has a known security advisory or has been flagged as malware. Composer 2.10, released in May 2026, added a unified policy configuration that blocks packages with advisories and packages flagged as malware during updates by default. Commit composer.lock and deploy with composer install, so production runs exactly the versions you tested.

Quick review table

RiskBuilt-in defenceCommon mistake
Stolen password databasepassword_hash(), password_verify()MD5 or SHA-256 hashes, short hash columns
SQL injectionPDO or mysqli prepared statementsInterpolated ORDER BY or table names
XSShtmlspecialchars(), json_encode() with hex flagsUnescaped values in attributes or scripts
Session hijacking or fixationStrict mode, cookie flags, session_regenerate_id(true)Relying on default INI values
CSRFrandom_bytes() tokens, hash_equals()Comparing tokens with ==
Known vulnerabilitiesSupported PHP branch, composer auditRunning end-of-life PHP

Next steps

Use this checklist in your next code review, starting with the areas that handle authentication and payments. Fix the session settings and password rehashing first, because they protect every user at once. Then add composer audit to CI and put your PHP branch's end-of-support date in the team calendar. For deeper reading, the Security section of the PHP manual covers file uploads, filesystem access and database security in more detail.

Frequently asked questions

  • Is PASSWORD_DEFAULT still bcrypt?

    Yes. The PHP manual says PASSWORD_DEFAULT currently uses bcrypt and may change to a stronger algorithm in future, so store hashes in a column of 255 characters. Since PHP 8.4 the default bcrypt cost is 12.

  • Do prepared statements protect ORDER BY and table names?

    No. Placeholders only bind values. Identifiers such as column names and sort directions must be checked against a fixed allowlist in your code.

  • Is session.use_strict_mode enabled by default?

    No. The PHP manual describes it as mandatory for secure sessions but disabled by default, so enable it explicitly in php.ini or in the options passed to session_start().

  • Are SameSite cookies enough to stop CSRF?

    They reduce the risk but do not replace CSRF tokens. Use a random token from random_bytes() per session and compare it with hash_equals().

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. password_hash — PHP Manual
  2. PDO: Errors and error handling — PHP Manual
  3. htmlspecialchars — PHP Manual
  4. Securing Session INI Settings — PHP Manual
  5. Supported Versions — The PHP Group
  6. Migrating from PHP 8.4.x to PHP 8.5.x: Backward Incompatible Changes — PHP Manual
  7. Composer 2.10 release announcement — Packagist blog, 2026-05-28

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.