PHP Security Basics
Writing PHP that resists the common attacks: escaping output, prepared statements, password hashing, CSRF tokens, safe file uploads and includes, unserialize, and keeping PHP up to date.
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.
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 <script>alert("Hi")</script></h1> <input name="street" value="Bakker's "Hof" & Zn">
Escape at the moment of output, not when the data arrives. If you
escape on input and store < 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'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: usejson_encode()with theJSON_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()(orrawurlencode()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: onlyhttps:orhttp:, neverjavascript:.
<?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
.phpfile 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) andpost_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()andpassword_needs_rehash(). -
Tokens from
random_bytes(), compared withhash_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()oreval()on input; shell arguments escaped or avoided. -
Errors logged, not displayed; secure session settings; a supported
PHP version;
composer auditin the pipeline.