Where PHP applications go wrong

PHP's security record is mostly the record of the code written in it. The language makes it easy to put a value from a form straight into a page, a query or a file name, and decades of tutorials did exactly that. The attacks are the general web attacks described in web application security and the OWASP Top 10 (2025 edition: injection is A05, software supply chain failures A03, mishandled exceptional conditions A10). This page shows the PHP functions and habits that prevent them.

Two rules carry most of the weight. First, everything in a request is under the sender's control: $_GET, $_POST, $_COOKIE, $_FILES, and most of $_SERVER (headers, the host name, the user agent). Check it on the server with input validation: expected type, length, range or allowed values, as shown in PHP and the web. Second, data is only dangerous in a context: the same text is harmless in a log file and harmful inside a SQL query or an HTML page. So each destination gets its own protection, applied at the moment the data goes there.

Untrusted input from $_GET, $_POST, $_COOKIE and $_FILES is first validated for type, length and range. Where it then goes decides the protection: HTML output uses htmlspecialchars, a SQL query uses a prepared statement, JavaScript inside a page uses json_encode with the JSON_HEX flags, and a file path or include uses an allowlist of keys. Untrusted input $_GET, $_POST $_COOKIE $_FILES, headers Validate type, length, range HTML output htmlspecialchars() SQL query prepared statement JavaScript in a page json_encode() + JSON_HEX_* File path or include allowlist of keys
Validation checks that a value makes sense; it does not make the value safe everywhere. The protection depends on where the value goes next.

Escaping output against XSS

Cross-site scripting (XSS) happens when text from a user ends up in a page as markup, so the browser runs it as part of your site. In PHP the fix is htmlspecialchars(), which turns the characters that HTML treats specially into entities. The browser then shows the text exactly as typed, and none of it becomes a tag. Most projects wrap it in a short helper, because it is needed on every line that prints a variable:

<?php
// Simulate the request GET /outages?area=<script>alert("Hi")</script>
$_GET = ['area' => '<script>alert("Hi")</script>'];

function e(string $text): string
{
    return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

$area = $_GET['area'] ?? '';
echo '<h1>Outages in ' . e($area) . "</h1>\n";
echo '<input name="street" value="' . e("Bakker's \"Hof\" & Zn") . '">' . "\n";
<h1>Outages in &lt;script&gt;alert(&quot;Hi&quot;)&lt;/script&gt;</h1>
<input name="street" value="Bakker&#039;s &quot;Hof&quot; &amp; Zn">

Escape at the moment of output, not when the data arrives. If you escape on input and store &lt; in the database, the value is wrong for every other use (an email, a CSV export, a JSON API), and it is easy to escape twice or forget the one value that took another route. Store what the user typed; escape for the place you put it.

Quotes and attributes

Inside an attribute value, a quote character would end the value and let the rest of the input add attributes of its own. That is why the flags matter. Since PHP 8.1 the default is ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, which escapes both quote types and replaces invalid UTF-8 bytes; older versions left single quotes alone. Passing the flags and 'UTF-8' explicitly, as the helper above does, gives the same result on every version:

<?php
$name = "O'Brien";
// The old default (before PHP 8.1) left single quotes alone
echo "<input value='" . htmlspecialchars($name, ENT_COMPAT) . "'>\n";
// The default since PHP 8.1: ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
echo "<input value='" . htmlspecialchars($name) . "'>\n";
<input value='O'Brien'>
<input value='O&#039;Brien'>

In the first line the apostrophe closes the attribute early. With a friendly name that only breaks the form; with hostile input the rest of the value becomes new attributes, such as an event handler. Always quote attribute values, and never put user input in an unquoted attribute, an event handler attribute (onclick) or a <style> block.

Other output contexts

htmlspecialchars() is right for text between tags and in quoted attributes. Other places need their own encoding:

  • Inside a <script> block: use json_encode() with the JSON_HEX_* flags, which produce a valid JavaScript value and encode <, >, & and both quotes so the text cannot close the script element.
  • In a URL: encode each query value with urlencode() (or rawurlencode() for path segments; see percent-encoding), then escape the whole URL for the HTML attribute. A full URL from a user also needs its scheme checked: only https: or http:, never javascript:.
<?php
$area = '</script><b>Den Haag</b>';
$flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR;
echo '<script>const area = ' . json_encode($area, $flags) . ";</script>\n";

$query = urlencode('Den Haag & Delft');
echo '<a href="/outages?area=' . htmlspecialchars($query) . '">Outages</a>' . "\n";
<script>const area = "\u003C\/script\u003E\u003Cb\u003EDen Haag\u003C\/b\u003E";</script>
<a href="/outages?area=Den+Haag+%26+Delft">Outages</a>

Template engines such as Twig, and Laravel's Blade with {{ $area }}, escape by default, which removes most of this work. Their raw-output escape hatches (|raw, {!! !!}) deserve the same suspicion as a bare echo. A Content Security Policy adds a second line of defense in the browser.

Prepared statements against SQL injection

SQL injection happens when input is glued into the text of a SQL statement, so the database cannot tell your SQL from the user's. Escaping functions such as addslashes() are not a fix: they miss cases, and they are easy to forget. The fix is a parameterized query, called a prepared statement in PHP: the SQL text is fixed, and the values travel separately, so they can never change its structure.

<?php
// Unsafe: the input becomes part of the SQL text
$sql = "SELECT id, name FROM customers WHERE email = '" . $_POST['email'] . "'";
$customers = $pdo->query($sql)->fetchAll();

// Safe: fixed SQL text with a placeholder; the value is sent separately
$stmt = $pdo->prepare('SELECT id, name FROM customers WHERE email = :email');
$stmt->execute(['email' => $_POST['email'] ?? '']);
$customers = $stmt->fetchAll();

The details (connecting, placeholders, fetch modes, transactions) and runnable examples are on databases with PDO. The same idea exists in the other database APIs you meet in older code: mysqli has prepare() with bind_param(), and WordPress code uses $wpdb->prepare() with %s and %d placeholders. The old mysql_* functions were removed in PHP 7.0; code that still uses them cannot run on a supported PHP version.

Names cannot be parameters

Placeholders stand for values. Table names, column names and keywords such as ASC/DESC cannot be parameters. When a request picks the sort column, map it to an allowlist and use only your own string in the SQL:

<?php
$sortColumns = ['name' => 'name', 'city' => 'city', 'joined' => 'created_at'];
$column = $sortColumns[$_GET['sort'] ?? ''] ?? 'name';
$direction = ($_GET['dir'] ?? '') === 'desc' ? 'DESC' : 'ASC';

$stmt = $pdo->prepare("SELECT id, name, city FROM customers ORDER BY $column $direction");
$stmt->execute();

Give the application's database account only the rights it needs (least privilege for the application), so that a mistake elsewhere cannot drop tables.

Storing passwords

Passwords must be stored with password hashing: a slow, salted one-way function, so a stolen user table does not reveal the passwords. PHP has had this built in since 5.5. password_hash() creates a hash with a fresh random salt and stores the algorithm, the cost and the salt inside the result, and password_verify() checks a password against it. You never handle the salt yourself.

<?php
$password = 'lilac-kettle-91-orbit';

$hash = password_hash($password, PASSWORD_DEFAULT);
$again = password_hash($password, PASSWORD_DEFAULT);

var_dump(str_starts_with($hash, '$2y$'));   // bcrypt
var_dump(strlen($hash));                     // today 60; use a VARCHAR(255) column
var_dump($hash === $again);                  // a new random salt every time
var_dump(password_verify($password, $hash));
var_dump(password_verify($password, $again));
var_dump(password_verify('Lilac-kettle-91-orbit', $hash));
bool(true)
int(60)
bool(false)
bool(true)
bool(true)
bool(false)

PASSWORD_DEFAULT is bcrypt (hashes start with $2y$). The default cost, the work factor, was raised from 10 to 12 in PHP 8.4. bcrypt only uses the first 72 bytes of a password, which matters only for very long passphrases. Argon2id is available as PASSWORD_ARGON2ID when PHP was built with Argon2 support. The salt option has been ignored since PHP 8.0: never supply your own.

Verifying and rehashing

Because the settings live inside each hash, old hashes keep working after you raise the cost or change the algorithm. password_needs_rehash() tells you, at login, while you briefly have the plain password, whether to store a new hash:

<?php
// A user record from the database, hashed years ago with cost 10
$user = [
    'email' => 'amara@example.com',
    'password_hash' => password_hash('lilac-kettle-91-orbit', PASSWORD_DEFAULT, ['cost' => 10]),
];
$options = ['cost' => 12];

function log_in(array &$user, string $password, array $options): bool
{
    if (!password_verify($password, $user['password_hash'])) {
        return false;
    }
    if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT, $options)) {
        $user['password_hash'] = password_hash($password, PASSWORD_DEFAULT, $options);
        echo "Hash upgraded to cost {$options['cost']}\n";   // save it to the database here
    }
    return true;
}

var_dump(log_in($user, 'wrong-guess', $options));
var_dump(log_in($user, 'lilac-kettle-91-orbit', $options));
var_dump(log_in($user, 'lilac-kettle-91-orbit', $options));
var_dump(password_get_info($user['password_hash'])['options']);
bool(false)
Hash upgraded to cost 12
bool(true)
bool(true)
array(1) {
  ["cost"]=>
  int(12)
}

After a successful login, also call session_regenerate_id(true), so an attacker who planted a session ID beforehand does not get a logged-in session (see sessions and cookies). Show the same message for "unknown email" and "wrong password", so the login form does not reveal which accounts exist.

Random values and CSRF tokens

Session IDs, password-reset links, API keys and CSRF tokens are only secret if nobody can predict them. PHP's rand() and mt_rand() are fast generators for games and simulations; given the same seed they produce the same numbers, and their state can be reconstructed from output. random_bytes() returns bytes from the operating system's cryptographically secure generator, and random_int() gives a secure whole number in a range. Both throw an exception rather than return something weak.

<?php
// mt_rand() is for simulations, not secrets: the same seed gives the same numbers
mt_srand(2026);
echo mt_rand(100000, 999999), "\n";
mt_srand(2026);
echo mt_rand(100000, 999999), "\n";

// random_bytes() and random_int() use the operating system's secure generator
$token = bin2hex(random_bytes(16));
echo strlen($token), " hex characters\n";
$code = random_int(100000, 999999);
var_dump($code >= 100000 && $code <= 999999);
782305
782305
32 hex characters
bool(true)

CSRF tokens

Cross-site request forgery (CSRF) makes a logged-in user's browser send a request the user did not intend, for example a form on another site that posts to /account/email. The browser adds the session cookie, so the request looks genuine. A CSRF token is a random secret stored in the user's session and placed in a hidden field of every form; the server accepts a state-changing request only when the submitted token matches. Another site can make the browser send the cookie, but it cannot read your page, so it cannot know the token.

<?php
// No real session on the command line: a plain array stands in for $_SESSION.
$_SESSION = [];

function csrf_token(): string
{
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

function csrf_is_valid(mixed $sent): bool
{
    return is_string($sent)
        && isset($_SESSION['csrf_token'])
        && hash_equals($_SESSION['csrf_token'], $sent);
}

$token = csrf_token();                  // put this in a hidden form field
echo strlen($token), " hex characters\n";
var_dump($token === csrf_token());      // one token for the whole session

// The real form posts the token back
$_POST = ['csrf_token' => $token, 'meter' => 'KE-4471'];
var_dump(csrf_is_valid($_POST['csrf_token'] ?? null));

// A form on another site cannot know the token
$_POST = ['meter' => 'KE-4471'];
var_dump(csrf_is_valid($_POST['csrf_token'] ?? null));
$_POST = ['csrf_token' => 'guess', 'meter' => 'KE-4471'];
var_dump(csrf_is_valid($_POST['csrf_token'] ?? null));
64 hex characters
bool(true)
bool(true)
bool(false)
bool(false)

In the page, the token goes into each form that changes something, escaped like any other output:

<form method="post" action="/meter-readings">
  <input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
  <label for="reading">Meter reading (kWh)</label>
  <input id="reading" name="reading" inputmode="numeric" required>
  <button>Submit reading</button>
</form>

hash_equals() compares in constant time, so the response time does not reveal how many leading characters were right. The token check comes on top of the other CSRF defenses: use POST (never GET) for changes, and set the session cookie's SameSite attribute. Frameworks such as Laravel and Symfony generate and check these tokens for you.

Files: includes and uploads

Includes chosen by the request

include and require load a file and run it as PHP. Old-style sites often picked the page from the URL with include $_GET['page'] . '.php';. Local file inclusion (LFI) is the vulnerability that follows: by sending a path with ../ segments, an attacker makes the script read or run a file you never meant to expose, such as a configuration file or an uploaded file containing PHP code. If remote URLs are allowed, it becomes remote file inclusion, which runs code from another server. PHP's allow_url_include setting is off by default; leave it off.

The fix is to never build a path from input. Map the request to an allowlist and use only your own paths:

<?php
// Only these pages exist; the request picks a key, never a path.
const PAGES = [
    'home'    => __DIR__ . '/pages/home.php',
    'outages' => __DIR__ . '/pages/outages.php',
    'contact' => __DIR__ . '/pages/contact.php',
];

function page_for(mixed $requested): string
{
    if (!is_string($requested) || !array_key_exists($requested, PAGES)) {
        return 'not-found';
    }
    return $requested;
}

$requests = ['outages', '../config/database', 'http://attacker.example/code', ['home'], null];
foreach ($requests as $requested) {
    $_GET = ['page' => $requested];
    $page = page_for($_GET['page'] ?? 'home');
    echo json_encode($requested, JSON_UNESCAPED_SLASHES), ' -> ', $page, "\n";
}
// Then: send a 404 for 'not-found', otherwise include PAGES[$page]
"outages" -> outages
"../config/database" -> not-found
"http://attacker.example/code" -> not-found
["home"] -> not-found
null -> home

Note the array: ?page[]=home in a query string makes $_GET['page'] an array, which is why the function checks the type. The same allowlist idea applies to downloads: look up the file by an ID in your database, never by a name from the request. If a name is unavoidable, keep only the last part with basename() and check that the final realpath() is still inside the intended folder.

File uploads

An uploaded file is untrusted input of any size and content. The file name and the MIME type in $_FILES come from the browser, so they say nothing reliable. A safe upload handler checks the error code and the size, determines the real type from the content, invents its own file name, and stores the file where the web server will not run it:

<?php
const MAX_BYTES = 2 * 1024 * 1024;
const ALLOWED = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'application/pdf' => 'pdf'];

$file = $_FILES['meter_photo'] ?? null;
if (!is_array($file) || !is_string($file['tmp_name'] ?? null) || $file['error'] !== UPLOAD_ERR_OK) {
    http_response_code(400);
    exit('The upload failed.');
}
if ($file['size'] > MAX_BYTES) {
    http_response_code(413);
    exit('The file is too large.');
}

// The real type, from the file's content (needs the fileinfo extension)
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!isset(ALLOWED[$mime])) {
    http_response_code(415);
    exit('Only JPEG, PNG or PDF files are accepted.');
}

// Our own random name and extension, in a folder outside the web root
$name = bin2hex(random_bytes(16)) . '.' . ALLOWED[$mime];
if (!move_uploaded_file($file['tmp_name'], '/var/lib/kestrel/uploads/' . $name)) {
    http_response_code(500);
    exit('The file could not be stored.');
}
  • Outside the web root. A file under the public folder can be requested directly, and a .php file there would be run by the server. Serve stored files through a script that checks the user's rights first.
  • Your own name. A random name avoids path tricks, overwriting other files and double extensions such as photo.php.jpg.
  • Limits. upload_max_filesize (2 MB by default) and post_max_size (8 MB) in php.ini cap what PHP accepts at all; your own check enforces the limit of each form.

unserialize, eval and shell commands

PHP's serialize() turns values, including objects, into a string, and unserialize() turns that string back into values. When the string comes from a user (a cookie, a hidden field, an upload), the user decides which classes are created and what their properties contain. When those objects are destroyed or used, magic methods such as __destruct() and __wakeup() run with the attacker's data. Object injection is the name for this attack: unserializing untrusted data so that existing classes in the application, or in its libraries, do something harmful, up to running code.

The rule is short: never call unserialize() on data from outside. For plain data use JSON, which can only produce arrays, strings, numbers, booleans and null. If you must read serialized data, forbid objects with the allowed_classes option:

<?php
class Report
{
    public string $title = 'Outages';
}

$stored = serialize(new Report());
echo $stored, "\n";

// Refuse to create objects: any object comes back as __PHP_Incomplete_Class
$data = unserialize($stored, ['allowed_classes' => false]);
echo get_class($data), "\n";

// Better: store plain data as JSON, which never creates objects of your classes
$json = json_encode(['title' => 'Outages', 'open' => 3]);
echo $json, "\n";
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
echo $data['title'], ': ', $data['open'], "\n";
O:6:"Report":1:{s:5:"title";s:7:"Outages";}
__PHP_Incomplete_Class
{"title":"Outages","open":3}
Outages: 3

eval and shell commands

eval() runs a string as PHP code, so input that reaches it can do anything the application can. There is always another way: an array of allowed callbacks, a match on known values, or a parser for the format you need.

Functions that start a shell (exec(), shell_exec(), system(), passthru() and the backtick operator) are the same risk for the operating system: input in the command line can add its own commands. Prefer a PHP function or library that does the job directly. If you must run a program, escape every argument with escapeshellarg(), or, since PHP 7.4, pass an array to proc_open(), which starts the program without a shell:

<?php
$host = $_GET['host'] ?? '';

// Unsafe: the input becomes part of a shell command line
// $result = shell_exec('ping -c 1 ' . $host);

// Validate first, then escape the argument
if (filter_var($host, FILTER_VALIDATE_IP) === false) {
    exit('Not an IP address.');
}
$result = shell_exec('ping -c 1 ' . escapeshellarg($host));

Configuration and updates

A few php.ini settings decide how much an attacker learns and how sessions behave. The production settings themselves (display_errors, logging, OPcache) are covered in deploying PHP; the security-relevant ones are:

; Never show errors to visitors; log them instead
display_errors = Off
log_errors = On

; Do not advertise the PHP version in an X-Powered-By header (on by default)
expose_php = Off

; Never load code from URLs (already the default)
allow_url_include = Off

; Session cookies: all four are off or empty by default
session.use_strict_mode = 1
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = "Lax"

Error messages shown in the page leak file paths, SQL fragments and library versions; they belong in a log. The session settings make PHP reject session IDs it did not create, send the cookie only over HTTPS, hide it from JavaScript (HttpOnly) and keep it out of most cross-site requests. Each is explained in sessions and cookies.

Keep PHP and packages up to date

Each PHP branch gets two years of bug fixes and then two years of security fixes only. As of October 2026, PHP 8.4 and 8.5 have full support, 8.3 and 8.2 receive security fixes only, and 8.2 reaches end of life on 31 December 2026. Everything older gets no fixes from the PHP project at all. Yet W3Techs (October 2026) counts 27.8% of PHP websites on PHP 7 and 7.8% on PHP 5. The upgrade process is described in upgrading PHP.

Most PHP vulnerabilities in practice are in the application and its libraries, not in PHP itself: WordPress plugins, outdated frameworks, abandoned packages. Keep them on the vulnerability management list too. For Composer projects, composer audit compares the installed packages against published security advisories and flags abandoned ones (see Composer and packages):

composer audit --locked

A clean audit only means no published advisory matched. A hijacked or typosquatted package is a supply chain attack that no advisory lists yet; review what you add, and commit composer.lock so every install gets the versions you tested.

A short checklist

  • Every printed variable goes through htmlspecialchars() (or an escaping template engine).
  • Every query with a variable uses a prepared statement; names come from an allowlist.
  • Passwords with password_hash(), password_verify() and password_needs_rehash().
  • Tokens from random_bytes(), compared with hash_equals(); a CSRF token on every state-changing form.
  • No path, class name or code from a request: includes, downloads and uploads go through allowlists and your own file names.
  • No unserialize() or eval() on input; shell arguments escaped or avoided.
  • Errors logged, not displayed; secure session settings; a supported PHP version; composer audit in the pipeline.