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 = Off costs nothing.

  • Keeping current: run a supported PHP version and patch it.

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 (/reviews/7/edit must check that review 7 belongs to the user), CSRF, SSRF from URL-fetching features

this page; Gates and Policies in Laravel

A02 Security Misconfiguration

display_errors, expose_php, default credentials, debug mode in production (APP_DEBUG=true), a document root above public/

this page

A03 Software Supply Chain Failures

Vulnerable or abandoned Composer packages, unpinned dependencies, untrusted Composer plugins

composer audit below; Composer and Packages

A04 Cryptographic Failures

md5() passwords, home-made encryption, rand() tokens, plain HTTP

Cryptography, Hashing and Passwords

A05 Injection (includes XSS)

SQL built from strings, shell commands, include with request data, eval(), unescaped output

this page, PDO and forms pages

A06 Insecure Design

No rate limiting on login, reset tokens that never expire, trusting client-side checks

Authentication Without a Framework

A07 Authentication Failures

Weak password storage, session fixation, credential stuffing

the authentication and sessions pages

A08 Software or Data Integrity Failures

unserialize() of untrusted data, unsigned cookies, unverified downloads and updates

this page

A09 Security Logging and Alerting Failures

No log of failed logins and authorization failures, logs that contain passwords or tokens

Error Handling and Logging

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

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 (or Strict) 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

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) or escapeshellarg() for every argument. See the PHP Reference’s Command-Line PHP.

  • PHP code: never eval(), create_function() (removed) or preg_replace() with /e (removed) on input; never assert() 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

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

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 .env file outside the document root and listed in .gitignore, loaded by a library such as vlucas/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 older X-Frame-Options: DENY against 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.

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_quotes and safe_mode (Kromann’s directive history still mentions safe_mode) were removed in PHP 5.4; open_basedir, containers and least privilege replace safe_mode.

  • FILTER_SANITIZE_STRING and "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-Options and X-XSS-Protection are superseded by CSP’s frame-ancestors and by browsers removing their XSS filters; do not rely on X-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).