Web Security Fundamentals
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against PHP 8.5 (8.5.11), checked on 2026-10-05, and maps PHP to the OWASP Top 10:2025. Every example was run with PHP 8.5.11 behind the built-in server, and the outputs are the real ones. Laravel enables most of these defences by default (CSRF protection, escaping, encrypted cookies, prepared statements); its specifics are on Security: CSRF, Encryption and Hashing. OAuth 2.0, OpenID Connect, JWTs and multi-factor authentication are covered in the OAuth section.
The PHP Manual’s security chapter
The manual’s Security part is short and still worth reading in full. Its themes, each covered in this section:
-
General considerations: complete security is impossible; balance risk, cost and usability, and assume every input is hostile.
-
Installed as CGI binary and as an Apache module: run PHP with the least privileges, keep scripts outside paths that can be requested directly (see The PHP Request Model and Servers).
-
Session security: see Sessions and Cookies.
-
Filesystem security and null bytes: never let a request choose a path (below).
-
Database security: least-privilege database accounts, encrypted storage of sensitive columns, and SQL injection (see Databases with PDO).
-
Error reporting: never show errors to users in production (below).
-
User-submitted data: validate everything (see Forms, Validation and Output Escaping).
-
Hiding PHP: security through obscurity is weak, but
expose_php = Offcosts nothing. -
Keeping current: run a supported PHP version and patch it.
Source: PHP Manual — Security
The OWASP Top 10:2025 for PHP
| Risk | In PHP | Where |
|---|---|---|
A01 Broken Access Control (includes SSRF) |
Checking the user on every request and every object ( |
this page; Gates and Policies in Laravel |
A02 Security Misconfiguration |
|
this page |
A03 Software Supply Chain Failures |
Vulnerable or abandoned Composer packages, unpinned dependencies, untrusted Composer plugins |
|
A04 Cryptographic Failures |
|
|
A05 Injection (includes XSS) |
SQL built from strings, shell commands, |
this page, PDO and forms pages |
A06 Insecure Design |
No rate limiting on login, reset tokens that never expire, trusting client-side checks |
|
A07 Authentication Failures |
Weak password storage, session fixation, credential stuffing |
the authentication and sessions pages |
A08 Software or Data Integrity Failures |
|
this page |
A09 Security Logging and Alerting Failures |
No log of failed logins and authorization failures, logs that contain passwords or tokens |
|
A10 Mishandling of Exceptional Conditions |
Errors that fail open (an exception in an access check that lets the request continue), error messages that leak internals |
this page |
Source: OWASP Top 10:2025
Cross-site request forgery (CSRF)
A CSRF attack makes the victim’s browser send a state-changing request to your site, from a page on another site, with the victim’s cookies attached. The server sees an authenticated request it never asked for. Two defences work together:
-
SameSite=Lax(orStrict) session cookies: the browser does not send the cookie on cross-site POSTs. This is the default in the upcoming PHP 8.6 and in Laravel, and it blocks most CSRF, but not attacks from a sibling subdomain or through GET requests that change state. -
A CSRF token: a random secret stored in the session and repeated in every form (a hidden field) or AJAX request (a header). A forged request from another site cannot know it.
<?php
declare(strict_types=1);
session_start(['cookie_httponly' => true, 'cookie_samesite' => 'Lax', 'use_strict_mode' => true]);
function csrf_token(): string
{
return $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}
function verify_csrf(): void
{
$sent = $_POST['_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!is_string($sent) || !hash_equals(csrf_token(), $sent)) {
http_response_code(419);
exit("CSRF token mismatch\n");
}
}
// in every form: <input type="hidden" name="_token" value="<?= csrf_token() ?>">
// in every POST, PUT, PATCH and DELETE handler, before doing anything:
verify_csrf();
$ curl -c jar -b jar -X POST localhost:8000/reviews/7/delete # no token
CSRF token mismatch # 419
$ curl -c jar -b jar -X POST localhost:8000/reviews/7/delete -d _token=0000 # wrong token
CSRF token mismatch # 419
$ curl -c jar -b jar -X POST localhost:8000/reviews/7/delete -d "_token=$TOKEN" # token from the form
review 7 deleted # 200
$ curl -c jar -b jar -X POST localhost:8000/reviews/7/delete -H "X-CSRF-Token: $TOKEN"
review 7 deleted
Source: OWASP — CSRF Prevention Cheat Sheet and hash_equals
The token is compared with hash_equals() (constant time), and GET requests must never change state, because they are
not protected. Laravel 13 goes further: its PreventRequestForgery middleware also checks the browser’s
Sec-Fetch-Site header.
Injection
The rule behind every kind of injection is the same: data must never become code (SQL, a shell command, a file path, PHP source, HTML). Keep them apart with an API that takes data separately.
-
SQL: prepared statements, always; whitelists for identifiers. See Databases with PDO.
-
Shell commands: avoid them; when unavoidable,
proc_open()with an argument array (no shell involved) orescapeshellarg()for every argument. See the PHP Reference’s Command-Line PHP. -
PHP code: never
eval(),create_function()(removed) orpreg_replace()with/e(removed) on input; neverassert()with a string (removed in 8.0). -
HTML (XSS): escape on output for the context. See Forms, Validation and Output Escaping.
File inclusion
include, require and the file functions accept paths, so a path built from the request lets the attacker choose
which file runs or is read:
<?php
declare(strict_types=1);
// VULNERABLE: the request chooses the path
function show(string $page): void
{
include __DIR__ . '/pages/' . $page . '.php';
}
show('about'); // About Bookshelf
var_export(include __DIR__ . '/pages/' . '../secret/config' . '.php'); // traversal: any .php file is reachable
// array ('dsn' => 'mysql:host=db.internal;dbname=bookshelf')
<?php
declare(strict_types=1);
// SAFE: the request picks a key, never a path
$pages = ['about' => 'about.php', 'faq' => 'faq.php'];
$file = $pages[$_GET['p'] ?? ''] ?? null;
if ($file === null) {
http_response_code(404);
exit("unknown page\n");
}
require __DIR__ . '/../pages/' . $file;
// GET /page?p=../../etc/passwd -> 404 unknown page
The classic null-byte trick (?p=../../etc/passwd%00, cutting off the .php suffix) no longer works: tested on 8.5,
include "pages/notes.txt\0.php" refused to open the file even though notes.txt existed, file_exists() returned
false, and file_get_contents() threw ValueError: ... must not contain any null bytes. Traversal itself
(../secret/config) still works, so the allowlist is the fix. Remote inclusion (include 'http://…') is blocked by
allow_url_include, which is 0 by default and deprecated since 7.4; never enable it.
Server-side request forgery (SSRF)
Any feature that fetches a URL supplied by the user ("import a cover image from a URL", webhooks, link previews) can be
turned against the server: the request comes from inside the network, so it reaches localhost, internal services
and the cloud metadata endpoint (169.254.169.254), and with PHP’s stream wrappers it can also read local files. The
naive version does all of that:
<?php
declare(strict_types=1);
// VULNERABLE: "import a cover image from a URL"
function fetchCoverNaive(string $url): string
{
return file_get_contents($url); // any scheme, any host
}
echo substr(fetchCoverNaive('file:///etc/hostname'), 0, 40), "\n"; // 046dbde09bfa: a local file
echo substr(fetchCoverNaive('php://filter/convert.base64-encode/resource=/w/secret/config.php'), 0, 40), "\n";
// PD9waHAgcmV0dXJuIFsiZGJfcGFzc3dvcmQiID0+: the source of a PHP file, base64-encoded
The guarded version parses the URL with the URI extension, allows only https, resolves the host and rejects private,
loopback, link-local and reserved addresses, then makes cURL connect to the checked address (so a DNS answer that
changes between the check and the request, DNS rebinding, does not help) and refuses redirects:
<?php
declare(strict_types=1);
function fetchCover(string $input): string
{
$url = Uri\WhatWg\Url::parse($input) ?? throw new InvalidArgumentException('Invalid URL');
if ($url->getScheme() !== 'https') {
throw new InvalidArgumentException('Only https URLs are allowed');
}
$host = trim($url->getAsciiHost(), '[]');
$ips = filter_var($host, FILTER_VALIDATE_IP) !== false ? [$host] : (gethostbynamel($host) ?: []);
foreach ($ips as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
throw new InvalidArgumentException("Address $ip is not public");
}
}
if ($ips === []) {
throw new InvalidArgumentException('Host does not resolve');
}
$ch = curl_init($url->toAsciiString());
curl_setopt_array($ch, [
CURLOPT_RESOLVE => ["$host:443:{$ips[0]}"], // connect to the address that was checked
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_FOLLOWLOCATION => false, // a redirect could point anywhere
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_MAXFILESIZE => 2 * 1024 * 1024,
]);
return curl_exec($ch) ?: throw new RuntimeException(curl_error($ch));
}
file:///etc/passwd Only https URLs are allowed
http://bookshelf.test/cover.png Only https URLs are allowed
https://127.0.0.1/admin Address 127.0.0.1 is not public
https://169.254.169.254/latest/meta-data/ Address 169.254.169.254 is not public
https://[::1]/ Address ::1 is not public
https://10.0.0.5/ Address 10.0.0.5 is not public
https://localhost/ Address 127.0.0.1 is not public
Source: Uri\WhatWg\Url, Validation filters (FILTER_FLAG_NO_PRIV_RANGE), curl_setopt and OWASP — SSRF Prevention Cheat Sheet
gethostbynamel() only returns IPv4 addresses; a production version should also resolve AAAA records
(dns_get_record($host, DNS_AAAA)), and the strongest control is at the network level: an egress proxy or firewall
that blocks internal ranges for the application servers. The PHP proxy script in Nixon’s book (a page that fetches and
prints any URL passed to it) is exactly this vulnerability.
Insecure deserialization
unserialize() on data that a user can modify (a cookie, a hidden field, a cache entry an attacker can write) can
instantiate arbitrary classes, and their magic methods (__wakeup(), __destruct()) then run with attacker-chosen
property values: PHP object injection, which can lead to remote code execution through classes in vendor/
(gadget chains). Exchange data with clients as JSON; if unserialize() is unavoidable, pass
['allowed_classes' => false] or an explicit list, and sign the data with hash_hmac(). The PHP Reference’s
JSON, XML and Serialization page shows allowed_classes
in use.
Security misconfiguration
The production php.ini settings that matter for security:
display_errors = Off ; never show errors, paths and SQL to users
display_startup_errors = Off
log_errors = On ; log them instead
error_reporting = E_ALL
expose_php = Off ; no X-Powered-By: PHP/8.5.11 header
allow_url_include = 0 ; the default; never enable it
open_basedir = /srv/bookshelf:/tmp ; file functions cannot leave these directories
disable_functions = exec,passthru,shell_exec,system,proc_open,popen ; if the application does not need them
session.cookie_secure = 1
session.cookie_httponly = 1
session.use_strict_mode = 1
Source: PHP Manual — Core php.ini directives, Error reporting and OWASP — PHP Configuration Cheat Sheet
open_basedir limits the file functions, include and the stream wrappers to the listed directories:
<?php
// php -d open_basedir=/w ...
var_dump(@file_get_contents('/etc/passwd')); // bool(false)
echo error_get_last()['message'], "\n"; // file_get_contents(/etc/passwd): Failed to open stream: Operation not permitted
var_dump(strlen(file_get_contents('/w/pages/faq.php'))); // int(20): inside the allowed tree
Source: open_basedir
It is a useful second layer, not a sandbox: it does not restrict shell commands (exec() can read any file the PHP
user can), extensions or the database. Containers, a dedicated system user per application and read-only file systems
are stronger isolation. disable_functions cannot be set at run time and breaks code that needs those functions
(Composer scripts, queue workers that spawn processes), so apply it to the web SAPI’s php.ini only.
Secrets
Database passwords, API keys and signing keys belong in the environment (or a secrets manager), never in the repository:
-
read them with
getenv(); in development, a.envfile outside the document root and listed in.gitignore, loaded by a library such asvlucas/phpdotenv(what Laravel uses); -
give each environment its own secrets, and rotate a secret that has ever been committed (deleting the commit is not enough);
-
do not log them, and keep them out of error pages and backtraces (
#[\SensitiveParameter], see the PHP Reference’s Debugging and Profiling).
Security headers
A few response headers make the browser enforce extra rules. They do not replace correct escaping and access checks, but they reduce the damage when one is missed:
<?php
declare(strict_types=1);
header("Content-Security-Policy: default-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'");
header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); // only meaningful over HTTPS
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
header_remove('X-Powered-By');
Content-Security-Policy: default-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
-
Content-Security-Policy restricts where scripts, styles, images and frames may come from; with
default-src 'self'and no'unsafe-inline', an injected<script>does not run. Inline scripts then need a nonce. The policy and its rollout are explained in depth in the Django section’s Content Security Policy page, and apply unchanged to PHP.frame-ancestors 'none'replaces the olderX-Frame-Options: DENYagainst clickjacking. -
Strict-Transport-Security (HSTS) makes the browser use HTTPS only, for the given time.
-
X-Content-Type-Options: nosniff stops browsers from treating an upload as HTML or script.
-
Referrer-Policy keeps full URLs (which may contain tokens) from leaking to other sites.
These headers are usually set once, in the web server configuration or in a middleware. Cross-origin API access
(Access-Control-Allow-Origin) is a separate topic: see CORS.
Keeping PHP and dependencies patched
Run a supported PHP branch (see the PHP Reference’s
What’s New and Migration for the support timeline) and
apply patch releases; many are security releases. For dependencies, composer audit checks composer.lock against
the security advisories database, and composer install reports advisories too; since Composer 2.9 it can block
installing vulnerable versions:
$ composer audit
No security vulnerability advisories found.
$ composer audit --abandoned=report # also list abandoned packages
No security vulnerability advisories found.
Source: Composer — audit and PHP — Supported Versions
Run it in CI, and let a dependency bot (Dependabot, Renovate) propose updates.
Where older books and tutorials are outdated
-
The open proxy in Nixon’s book, a script that fetches and prints any URL it is given, is an SSRF hole; never fetch user-supplied URLs without the checks above.
-
register_globals,magic_quotesandsafe_mode(Kromann’s directive history still mentionssafe_mode) were removed in PHP 5.4;open_basedir, containers and least privilege replacesafe_mode. -
FILTER_SANITIZE_STRINGand "sanitising input" as an XSS defence (Beak ch. 6 and 12, Kromann ch. 13): validate input, escape output. -
The null-byte file trick still appears in older security chapters; PHP rejects paths with null bytes, but path traversal does not need them.
-
X-Frame-OptionsandX-XSS-Protectionare superseded by CSP’sframe-ancestorsand by browsers removing their XSS filters; do not rely onX-XSS-Protection. -
OWASP Top 10 2017 or 2021 lists in older material: the current edition is 2025 (supply chain failures and mishandling of exceptional conditions are new; SSRF is now part of broken access control).
References
-
PHP Manual — Security: general, filesystem, database, error reporting, user-submitted data, hiding PHP, keeping current
-
PHP Manual — Core php.ini directives (
open_basedir,disable_functions,expose_php,allow_url_include) -
OWASP Cheat Sheets: PHP Configuration, CSRF Prevention, SSRF Prevention, HTTP Security Headers
-
Andrew Beak — PHP 7 Zend Certification Study Guide (Apress, 2017), Frank M. Kromann — Beginning PHP and MySQL, 5th ed. (Apress, 2018) and Robin Nixon — Learning PHP, MySQL & JavaScript, 5th ed. (O’Reilly, 2018)