> TeamDevDesign
What are you looking for?

Explore our services and discover how we can help you achieve your goals

PHP Security Vulnerabilities: 11 Flaws & Fixes (2026)
  1. Home

  2. Website Security

  3. PHP Security Vulnerabilities: 11 Common Flaws and Fixes (2026)

PHP Security Vulnerabilities: 11 Common Flaws and Fixes (2026)

Common PHP security vulnerabilities in 2026: SQL injection, XSS, CSRF, file uploads, deserialization, php.ini and PHP versions, with secure vs insecure code.

Team Dev Design
Team Dev Design

18 mins to read
PHP Security Vulnerabilities: 11 Common Flaws and Fixes (2026)
Contents

Most hacked PHP sites are not breached by brilliant attackers. They fall to familiar mistakes: user input concatenated into SQL, raw data echoed into HTML, unchecked file uploads, or a PHP version that no longer receives patches. This guide covers the most common PHP security vulnerabilities in 2026, with insecure and secure code side by side so you can compare and fix your own project.

It is written for PHP developers and people who run their own sites. TeamDevDesign follows the OWASP Top 10:2025 and the official documentation on php.net. This is a defensive guide: we explain each weakness conceptually and do not publish exploit payloads.

Table of contents


What PHP security vulnerabilities are and why they still matter in 2026

A PHP security vulnerability is a weakness in PHP code, server configuration or the PHP interpreter itself that lets someone do what they should not: read or change data, hijack a login session or run commands on the server. There are two groups:

  • Application code flaws: SQL injection, XSS, CSRF, file uploads, file inclusion and so on. Developers control these directly, and they are the focus of this guide.
  • Flaws in PHP itself: fixed only by updating. The latest example: on 21 October 2026, php.net published an advisory about two unauthenticated remote code execution flaws (CVE-2026-109618 and CVE-2026-109619, CVSS v4.0 score 9.2). The fixes ship in PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12. According to the official advisory, updating to a patched release is the only complete mitigation. On 23 October 2026, php.net also advised setting session.upload_progress.enabled=0 until the next bugfix release (CVE-2026-115560, with full details to follow). Keep an eye on php.net for updates.

The latest OWASP Top 10 is the 2025 edition. Compared with 2021 it adds two categories: Software Supply Chain Failures and Mishandling of Exceptional Conditions. The table maps the PHP issues in this guide to OWASP categories:

OWASP Top 10:2025 categoryTypical PHP issueSection
A01 Broken Access ControlPath traversal, CSRF, access to other users' files3, 5
A02 Security Misconfigurationdisplay_errors on, expose_php, executable upload folder4, 9
A03 Software Supply Chain FailuresComposer packages, unpatched PHP10, 11
A04 Cryptographic FailuresPasswords stored with md5/sha18
A05 InjectionSQL injection, XSS, command injection1, 2, 6
A07 Authentication FailuresSession fixation, weak cookie flags8
A08 Software or Data Integrity Failuresunserialize on untrusted data7
A10 Mishandling of Exceptional ConditionsLeaked stack traces, unhandled errors9

If you run WordPress, many of these flaws appear in plugins and themes. See our guides on common WordPress security vulnerabilities and handling WordPress plugin vulnerabilities for the site-owner view.

1. SQL injection: prevent it in PHP with PDO prepared statements

SQL injection happens when user input is concatenated into a SQL statement. The input can then change the structure of the query: bypass a filter, read other tables or delete data. It remains one of the most damaging PHP security vulnerabilities because it reaches the database directly.

Insecure code

// INSECURE: string concatenated straight into SQL
$email = $_POST['email'];
$sql = "SELECT id, name FROM users WHERE email = '$email'";
$user = $pdo->query($sql)->fetch();

Secure code with PDO

// SECURE: PDO + prepared statement
$pdo = new PDO('mysql:host=localhost;dbname=app;charset=utf8mb4', $dbUser, $dbPass, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
]);

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

A prepared statement sends the query structure and the data separately, so input is treated only as a value. Three details people often miss:

  • Set charset=utf8mb4 in the DSN instead of running SET NAMES separately.
  • Set PDO::ATTR_EMULATE_PREPARES => false to use native MySQL prepares where possible.
  • Table names, column names and ORDER BY direction cannot be bound. Use an allowlist.
// Column names / sort direction cannot be bound: use an allowlist
$sortable = ['created_at', 'price', 'name'];
$sort  = in_array($_GET['sort'] ?? '', $sortable, true) ? $_GET['sort'] : 'created_at';
$order = (($_GET['order'] ?? '') === 'asc') ? 'ASC' : 'DESC';
$stmt = $pdo->prepare("SELECT * FROM products ORDER BY $sort $order LIMIT :limit");
$stmt->bindValue('limit', 20, PDO::PARAM_INT);
$stmt->execute();

Note: mysqli_real_escape_string() and addslashes() are not the primary defence. They are easy to misuse (for example, numeric values without quotes). Prefer prepared statements; see the PDO documentation on php.net.

2. XSS: escape output with htmlspecialchars and add Content-Security-Policy

Cross-site scripting (XSS) happens when a site prints user data without encoding it. The browser interprets that data as HTML or JavaScript and runs it in the visitor's session, which can lead to stolen sessions or actions performed on their behalf.

Insecure code

<!-- INSECURE: user data echoed as-is -->
<p>Search results for: <?= $_GET['q'] ?></p>

Secure code

<?php
function e(?string $value): string
{
    return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}
?>
<!-- SECURE: escape at the point of output -->
<p>Search results for: <?= e($_GET['q'] ?? '') ?></p>
<input type="text" name="q" value="<?= e($_GET['q'] ?? '') ?>">

The rule is escape on output, for the right context. Since PHP 8.1 the default flags of htmlspecialchars() are ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 (per php.net), but you should still pass ENT_QUOTES and 'UTF-8' explicitly so the code behaves the same on every version and default_charset setting. For data placed inside JavaScript, use json_encode() with JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT; for URLs, check the scheme and use rawurlencode() on each parameter.

Add a second layer: Content-Security-Policy

CSP is a header that tells the browser to run scripts only from sources you allow. If an XSS bug slips through, CSP can sharply reduce its impact. Start with Content-Security-Policy-Report-Only to observe, then enforce.

// Send the CSP header (before any output)
header("Content-Security-Policy: default-src 'self'; script-src 'self'; "
     . "object-src 'none'; base-uri 'self'; frame-ancestors 'self'");
header('X-Content-Type-Options: nosniff');

For detailed policies, see the OWASP CSP Cheat Sheet.

3. CSRF: use a token on every state-changing form

Cross-site request forgery (CSRF) abuses the fact that browsers send login cookies automatically. Another site can make a logged-in user unknowingly submit a request that changes their email, password or deletes data on your site. The standard defence is a random, session-bound token checked on every POST, PUT, PATCH and DELETE request.

Insecure code

// INSECURE: processes the request just because the user is logged in
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    updateEmail($_SESSION['user_id'], $_POST['email']);
}

Secure code

// SECURE: per-session token, verified on submit
session_start();
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']) ?>">

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $token = $_POST['csrf_token'] ?? '';
    if (!hash_equals($_SESSION['csrf_token'], $token)) {
        http_response_code(403);
        exit('Invalid request');
    }
    updateEmail($_SESSION['user_id'], $_POST['email']);
}

Use random_bytes() to generate the token and hash_equals() to compare it, which avoids timing leaks. Add SameSite=Lax cookies and never change state with GET requests. See the OWASP CSRF Prevention Cheat Sheet.

4. Insecure file uploads

Upload features are a common way to place malicious files on a server. The biggest risk is a file containing PHP code stored inside the web root, where it can be requested through the browser. Both the file name and $_FILES[...]['type'] are controlled by the sender and cannot be trusted.

Insecure code

// INSECURE: trusts the client-supplied file name and MIME type,
// and stores the file in a public folder
$name = $_FILES['avatar']['name'];
if ($_FILES['avatar']['type'] === 'image/jpeg') {
    move_uploaded_file($_FILES['avatar']['tmp_name'], __DIR__ . '/uploads/' . $name);
}

Safer code

// SAFER: check errors, size, real MIME via finfo, random file name
$file = $_FILES['avatar'] ?? null;
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];

if (!$file || $file['error'] !== UPLOAD_ERR_OK || $file['size'] > 2 * 1024 * 1024) {
    http_response_code(400);
    exit('Invalid file');
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime  = $finfo->file($file['tmp_name']);
if (!isset($allowed[$mime])) {
    http_response_code(415);
    exit('Unsupported format');
}

$newName = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
// Storage directory is OUTSIDE the web root, e.g. /var/www/app/storage/uploads
move_uploaded_file($file['tmp_name'], '/var/www/app/storage/uploads/' . $newName);

Four rules:

  1. Check the real MIME type with finfo, plus a format allowlist and a size limit.
  2. Rename the file with a random string and set the extension from the MIME type, never from the original name.
  3. Store outside the web root and serve files through a script that checks permissions.
  4. Block execution in the upload folder if files must stay public.
# Nginx: block PHP execution in the upload folder (if it must stay public)
location ~* ^/uploads/.*\.(php|phtml|phar)$ {
    deny all;
}

# Apache (.htaccess inside the uploads folder)
<FilesMatch "\.(php|phtml|phar)$">
    Require all denied
</FilesMatch>

For images, re-processing them (resize or re-encode with GD or Imagick) strips unexpected data. See the OWASP File Upload Cheat Sheet.

5. LFI/RFI and path traversal

Local File Inclusion (LFI) and Remote File Inclusion (RFI) happen when a user parameter decides which file is passed to include or require. Path traversal is the same idea with file-reading functions such as readfile() or file_get_contents(): path characters are used to escape the intended folder and read configuration files or secrets.

Insecure code

// INSECURE: include driven by a URL parameter
$page = $_GET['page'] ?? 'home';
include __DIR__ . '/pages/' . $page . '.php';

Secure code: an allowlist

// SECURE: only a fixed allowlist
$pages = [
    'home'    => 'home.php',
    'about'   => 'about.php',
    'contact' => 'contact.php',
];
$key = $_GET['page'] ?? 'home';
$file = $pages[$key] ?? $pages['home'];
include __DIR__ . '/pages/' . $file;

Reading files safely: realpath plus a base-directory check

// Downloads: normalise the path, then confirm it stays inside the allowed folder
$baseDir = realpath('/var/www/app/storage/invoices');
$path    = realpath($baseDir . '/' . basename($_GET['file'] ?? ''));

if ($path === false || !str_starts_with($path, $baseDir . DIRECTORY_SEPARATOR)) {
    http_response_code(404);
    exit;
}
readfile($path);

At the configuration level, keep allow_url_include = Off (it is off by default and has been deprecated since PHP 7.4, according to php.net). Add open_basedir to limit which directories PHP can touch.

6. Command injection: escapeshellarg and fewer shell calls

exec(), system(), shell_exec() and passthru() run commands through the operating system shell. If user input ends up in the command string, the shell may treat part of it as extra commands. The usual result is full control of the account PHP runs under.

Insecure code

// INSECURE: user data inside a shell command
$host = $_GET['host'];
echo shell_exec('ping -c 3 ' . $host);

Safer code

// SAFER: validate first, then escape each argument
$host = $_GET['host'] ?? '';
if (!filter_var($host, FILTER_VALIDATE_IP)) {
    exit('Invalid IP address');
}
echo shell_exec('ping -c 3 ' . escapeshellarg($host));

// Better (PHP 7.4+): pass an array to proc_open so NO shell is involved
$proc = proc_open(['ping', '-c', '3', $host], [1 => ['pipe', 'w']], $pipes);
echo stream_get_contents($pipes[1]);
fclose($pipes[1]);
proc_close($proc);

Order of preference: (1) use a PHP function or library instead of a shell command (for example Imagick instead of calling convert); (2) if you must run an external program, pass an array to proc_open() so no shell is involved; (3) if you still build a command string, validate strictly and wrap every argument in escapeshellarg(). On hosting that does not need these functions, disable them with disable_functions.

7. Insecure deserialization: never unserialize user input

unserialize() can create objects and trigger magic methods such as __wakeup() and __destruct(). Combined with classes already in your project or its libraries, attacker-controlled data can lead to file access or code execution. The official unserialize() documentation is explicit: do not pass untrusted input to it, even with allowed_classes set.

Insecure code

// INSECURE: unserialize data from a cookie
$cart = unserialize($_COOKIE['cart']);

Secure code: use JSON

// SECURE: use JSON for anything that passes through the client
try {
    $cart = json_decode($_COOKIE['cart'] ?? '[]', true, 16, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    $cart = [];
}
if (!is_array($cart)) {
    $cart = [];
}

If you must store serialized data somewhere users can reach, sign it with hash_hmac() and verify the signature before reading. JSON is still the simpler and safer choice.

8. Sessions and passwords: password_hash, session_regenerate_id, cookie flags

Authentication issues include weak password storage, keeping the same session ID after login (session fixation) and session cookies without protective flags. They are very common in hand-written PHP.

Passwords: no md5 or sha1

// INSECURE: md5/sha1 are not suitable for storing passwords
$hash = md5($_POST['password']);
// SECURE: password_hash + password_verify
$hash = password_hash($_POST['password'], PASSWORD_DEFAULT); // store in a VARCHAR(255) column

// On login
if ($user && password_verify($_POST['password'], $user['password_hash'])) {
    if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
        saveNewHash($user['id'], password_hash($_POST['password'], PASSWORD_DEFAULT));
    }
    session_regenerate_id(true);           // prevents session fixation
    $_SESSION['user_id'] = $user['id'];
}

According to the password_hash() documentation, PASSWORD_DEFAULT currently uses bcrypt, and the default cost rose from 10 to 12 in PHP 8.4. Bcrypt only uses the first 72 bytes of a password, and php.net recommends a 255-character column because the default algorithm may change. password_needs_rehash() lets you upgrade hashes gradually as users log in.

Sessions: HttpOnly, Secure and SameSite cookies

// Configure the session cookie before session_start() (PHP 7.3+)
session_set_cookie_params([
    'lifetime' => 0,
    'path'     => '/',
    'secure'   => true,     // HTTPS only
    'httponly' => true,     // not readable by JavaScript
    'samesite' => 'Lax',    // or 'Strict'
]);
ini_set('session.use_strict_mode', '1');
session_start();

Call session_regenerate_id(true) right after login or any privilege change. Enable session.use_strict_mode, which php.net describes as mandatory for secure sessions even though it is off by default. For WordPress sites, the equivalent steps are in our guide to securing the WordPress login.

9. Production php.ini settings

Many incidents come from default configuration rather than code. Displaying errors on screen leaks paths, table names and sometimes connection details. The X-Powered-By header reveals the exact PHP version. This is A02 Security Misconfiguration and A10 Mishandling of Exceptional Conditions in the OWASP Top 10:2025.

; php.ini for production
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/error.log
expose_php = Off
allow_url_include = Off
; Disable only if your app does not need them; test before applying
disable_functions = exec,passthru,shell_exec,system,proc_open,popen
open_basedir = /var/www/app/:/tmp/
file_uploads = On
upload_max_filesize = 8M
post_max_size = 10M
session.use_strict_mode = 1
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax
  • display_errors = Off with log_errors = On: visitors see a generic error page, details go to the log.
  • expose_php = Off: stop advertising your PHP version.
  • disable_functions: turn off command-execution functions if your app does not use them. Some plugins and backup tools need them, so test on staging first.
  • open_basedir: restrict PHP to the project directory.
  • Remove leftover phpinfo() and test files from the server.

If you manage your own server, pair this with our Linux VPS security checklist to harden the operating system layer.

10. Run a supported PHP version

No amount of careful code makes up for an interpreter with published vulnerabilities. According to the php.net Supported Versions page (checked October 2026), the supported branches are:

VersionActive support untilSecurity fixes untilStatus in Oct 2026
PHP 8.531 Dec 202731 Dec 2029Active support
PHP 8.431 Dec 202631 Dec 2028Active support
PHP 8.331 Dec 202531 Dec 2027Security fixes only
PHP 8.231 Dec 202431 Dec 2026Security fixes only, ending soon
PHP 8.1 and olderEnd of life (EOL list)No more patches

In practice: start new projects on PHP 8.4 or 8.5, and plan to move anything on PHP 8.2 before 31 December 2026. Watch patch releases too: during the 21 October 2026 advisory, a site on PHP 8.4 that had not reached 8.4.27 was still affected.

# Check Composer packages for published vulnerabilities
composer audit

# Show the running PHP version (CLI)
php -v

The composer audit command (part of the Composer CLI) flags dependencies with published vulnerabilities, which is exactly the new Software Supply Chain Failures category. If you use aaPanel, our aaPanel installation guide shows how to install and switch PHP versions.

11. Use a framework such as Laravel for built-in protection

Plain PHP means building every defence above yourself. A modern framework like Laravel ships most of them: Eloquent and the Query Builder bind parameters via PDO, Blade escapes {{ }} output automatically, CSRF middleware is on by default for web routes (Laravel CSRF docs), the Hash facade uses bcrypt or argon2, and validation covers file uploads.

// Laravel: built-in protections
User::where('email', $request->email)->first();        // automatic binding
DB::select('SELECT * FROM users WHERE id = ?', [$id]);  // raw, but still bound

{{ $comment->body }}      {{-- Blade escapes automatically --}}
{!! $comment->body !!}    {{-- NOT escaped: only for sanitised content --}}

<form method="POST" action="/profile">
    @csrf
</form>

$request->validate(['avatar' => 'required|file|mimes:jpg,png,webp|max:2048']);
Hash::make($request->password);

A framework does not do everything for you. Common slips remain: string-built whereRaw/DB::raw, {!! !!} with user data, mass assignment through $guarded = [], APP_DEBUG=true in production and outdated framework versions. Treat the framework as a solid foundation; secure PHP coding habits are still yours.

Secure PHP coding checklist

  • Every query uses PDO prepared statements; column names and sort direction use an allowlist.
  • Every value printed into HTML goes through htmlspecialchars(..., ENT_QUOTES, 'UTF-8') or an auto-escaping template engine.
  • A Content-Security-Policy header is in place, at least in Report-Only mode.
  • Every state-changing form has a CSRF token; nothing changes state via GET.
  • Uploads: real MIME via finfo, random names, storage outside the web root, PHP execution blocked.
  • No include, require or readfile driven by user input without an allowlist.
  • Minimal shell calls; when needed, escapeshellarg or proc_open with an array.
  • No unserialize on user data; use JSON.
  • Passwords via password_hash; session_regenerate_id(true) after login; HttpOnly, Secure, SameSite cookies.
  • php.ini: display_errors=Off, expose_php=Off, allow_url_include=Off, consider disable_functions and open_basedir.
  • A supported PHP branch on its latest patch release; run composer audit regularly.
  • Backups and logs so you can detect and recover from incidents.

Frequently asked questions about PHP security

Is PHP less secure than other languages?

Not really. Most PHP security vulnerabilities come from how code is written and configured, not from the language. PHP 8.x provides solid tools: PDO, password_hash, random_bytes and stricter typing. What matters is using them correctly and updating regularly.

Do prepared statements stop every SQL injection?

They cover most cases for values in a query. They do not protect table names, column names, ORDER BY or any SQL you build by concatenation. Those need an allowlist.

Can I still use PHP 8.2?

PHP 8.2 receives security fixes only until 31 December 2026, after which it is end of life. Plan an upgrade to 8.4 or 8.5 and test on staging before switching production.

How do I check which PHP version my site runs?

Run php -v on the server (the CLI and PHP-FPM can differ), check your hosting control panel, or briefly print PHP_VERSION from a protected file and delete it right away. Never leave a public phpinfo() file.

If I use Laravel, do I still need to think about PHP security?

Yes. Laravel removes many common mistakes, but raw queries, {!! !!}, debug settings and outdated packages are still risks. The PHP version underneath must also be kept up to date.

Are WordPress sites affected by PHP vulnerabilities?

Yes. WordPress runs on PHP, so flaws in the interpreter or in plugin and theme code both affect the site. If you suspect a compromise, see our steps for recovering a hacked WordPress site.

Conclusion

Most PHP security vulnerabilities can be avoided with a few consistent habits: keep data separate from commands, escape on output, validate everything users send, configure production carefully and update PHP on schedule. Nothing makes a site fully immune, but applying the checklist above reduces risk noticeably.

If you want someone to review your PHP code, update versions and monitor your site regularly, take a look at TeamDevDesign's website maintenance and management service or contact us to talk it through.

Share this post:

Contents
Related Posts
WordPress Security Vulnerabilities: 7 Types & 2026 Hardening Checklist
Website Security WordPress Security Vulnerabilities: 7 Types & 2026 Hardening Checklist

Common WordPress security vulnerabilities (XSS, SQLi, CSRF, broken access control), Patchstack 2025...

WordPress Plugin Vulnerabilities: How to Check & Prevent (2026)
Website Security WordPress Plugin Vulnerabilities: How to Check & Prevent (2026)

Plugins caused 91% of new WordPress vulnerabilities in 2025. See 5 real CVEs, the risks of nulled pl...

WordPress Site Hacked? How to Fix It Step by Step (2026)
Website Security WordPress Site Hacked? How to Fix It Step by Step (2026)

WordPress site hacked? Spot the signs, isolate the site, scan for malware, verify checksums with WP-...

dots Get new articles on web design, SEO and running a website.

Subscribe to our newsletter!

Weekly web design trends, SEO tips and practical ideas for growing your business online.

icon
icon
Live Chat
Live Chat Offline

Hi there! How can we help you today?

Powered by FOB Live Chat