Hi there! How can we help you today?
-
Home
-
Website Security
-
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.
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 matter in 2026
- 1. SQL injection: PDO prepared statements
- 2. XSS: output escaping and Content-Security-Policy
- 3. CSRF: tokens on every state-changing form
- 4. Insecure file uploads
- 5. LFI/RFI and path traversal
- 6. Command injection
- 7. Insecure deserialization
- 8. Sessions and passwords
- 9. Production php.ini settings
- 10. Run a supported PHP version
- 11. Use a framework such as Laravel
- Secure PHP coding checklist
- FAQ
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=0until 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 category | Typical PHP issue | Section |
|---|---|---|
| A01 Broken Access Control | Path traversal, CSRF, access to other users' files | 3, 5 |
| A02 Security Misconfiguration | display_errors on, expose_php, executable upload folder | 4, 9 |
| A03 Software Supply Chain Failures | Composer packages, unpatched PHP | 10, 11 |
| A04 Cryptographic Failures | Passwords stored with md5/sha1 | 8 |
| A05 Injection | SQL injection, XSS, command injection | 1, 2, 6 |
| A07 Authentication Failures | Session fixation, weak cookie flags | 8 |
| A08 Software or Data Integrity Failures | unserialize on untrusted data | 7 |
| A10 Mishandling of Exceptional Conditions | Leaked stack traces, unhandled errors | 9 |
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=utf8mb4in the DSN instead of runningSET NAMESseparately. - Set
PDO::ATTR_EMULATE_PREPARES => falseto use native MySQL prepares where possible. - Table names, column names and
ORDER BYdirection 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()andaddslashes()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:
- Check the real MIME type with
finfo, plus a format allowlist and a size limit. - Rename the file with a random string and set the extension from the MIME type, never from the original name.
- Store outside the web root and serve files through a script that checks permissions.
- 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 = Offwithlog_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:
| Version | Active support until | Security fixes until | Status in Oct 2026 |
|---|---|---|---|
| PHP 8.5 | 31 Dec 2027 | 31 Dec 2029 | Active support |
| PHP 8.4 | 31 Dec 2026 | 31 Dec 2028 | Active support |
| PHP 8.3 | 31 Dec 2025 | 31 Dec 2027 | Security fixes only |
| PHP 8.2 | 31 Dec 2024 | 31 Dec 2026 | Security fixes only, ending soon |
| PHP 8.1 and older | End 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
Common WordPress security vulnerabilities (XSS, SQLi, CSRF, broken access control), Patchstack 2025...
Plugins caused 91% of new WordPress vulnerabilities in 2025. See 5 real CVEs, the risks of nulled pl...
WordPress site hacked? Spot the signs, isolate the site, scan for malware, verify checksums with WP-...