The PHP vulnerabilities that still cause business website breaches
The flaws behind most compromised PHP business sites have been documented for twenty years and appear in every security syllabus. They keep reaching production anyway, because one input path was missed, a legacy function was never replaced, or nobody read the code again after the developer left.
This checklist covers what turns up repeatedly in live PHP business sites: SQL injection, cross-site scripting, unsafe file uploads, password storage, session handling, CSRF, and deserialisation. For each one, how it works, how to test for it, and what the correct fix looks like on PHP 8.3 or 8.4.
SQL injection, and why it survives
SQL injection has been publicly documented since 1998 and has appeared in every edition of the OWASP Top 10. The fix is well known. The reason it persists is that a single query built by string concatenation, on one page nobody thought about, is enough.
The mechanism is simple. When user input becomes part of the query text rather than a parameter, the attacker gets to change what the query means.
$email = $_POST['email'];
$query = "SELECT * FROM users WHERE email = '$email'";
$result = mysqli_query($conn, $query);
Submit ' OR '1'='1 as the email and the WHERE clause becomes permanently true, so the query returns the first row in the table. If that row happens to be an administrator, the login form has just handed over the application.
Prepared statements fix this properly, because the query structure is sent to the database separately from the values. No input, however malformed, can change the shape of the statement.
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute([$_POST['email']]);
$user = $stmt->fetch();
Two PDO settings are worth setting explicitly at connection time. Exception mode has been the default since PHP 8.0, but stating it makes the intent obvious to the next reader, and turning off emulated prepares means the database, rather than the driver, does the parameter binding.
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
If the codebase uses mysqli rather than PDO, note that since PHP 8.1 mysqli reports errors as exceptions by default. Older code written to check return values silently now throws instead, which is usually an improvement and occasionally a surprise during an upgrade.
Escaping functions such as mysqli_real_escape_string() are not a substitute. They work only when applied to every value on every path, and the failure mode of forgetting one is a full compromise. Where SQL injection sits against the other application risks is covered in the overview of the OWASP Top 10 for business web applications.
Cross-site scripting: attacking your visitors, not your server
XSS happens when user input is written into a page without being encoded for the context it lands in. SQL injection attacks the database; XSS attacks the people using the site, running attacker JavaScript with the same privileges as your own code.
Stored XSS is the worst of the three, because the payload is saved and served to everyone. A comment form that accepts HTML is the textbook case. Reflected XSS bounces the payload straight back, typically through a search term echoed into the results page, and needs the victim to follow a crafted link. DOM-based XSS never reaches the server at all: JavaScript on the page reads something from the URL and writes it into the document, which means server-side filtering cannot see it.
For HTML body content, htmlspecialchars() is the right tool.
echo htmlspecialchars($userInput, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
Since PHP 8.1 the default flags include ENT_QUOTES | ENT_SUBSTITUTE, so single quotes are escaped without asking and invalid byte sequences become a replacement character rather than an empty string. Older code that passed ENT_COMPAT, or relied on the pre-8.1 defaults, was leaving single quotes unescaped, which is exploitable inside a single-quoted attribute.
Context decides the encoder. HTML body content, an HTML attribute, a URL parameter and a JavaScript string literal all need different treatment, and using the HTML encoder inside a script block does not protect anything. Output encoding across those contexts is covered in the notes on XSS prevention and output encoding.
A Content Security Policy is the second layer. Configured with a nonce, it stops injected inline script from running even where an encoding mistake exists.
$nonce = base64_encode(random_bytes(16));
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-$nonce'; object-src 'none'; base-uri 'self'");
A policy carrying unsafe-inline in script-src provides no XSS protection at all, which is worth checking before assuming the header is doing something.
File uploads that end up executing
Profile photos, document submissions, ticket attachments. Upload forms are ordinary, and they are one of the most reliably exploited surfaces in PHP applications.
The attack is a PHP file dressed as an image. If the handler trusts the extension, or trusts the MIME type the browser claims, the file is written to a public directory and the attacker fetches its URL. The server executes it, and at that point the attacker has the database credentials and everything else the web user can reach.
- Server-side content inspection. Read the actual bytes with
finfo_file()rather than trusting$_FILES['x']['type'], which is supplied by the client. - Storage outside the web root. A file that has no URL cannot be requested, whatever it contains.
- Generated filenames. Discard the user's filename entirely and store under a random identifier, which also removes path traversal and encoding tricks in one move.
- Delivery through a script that checks authorisation, sets an explicit content type, and sends
Content-Disposition: attachmentfor anything that is not an image.
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['upload']['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
http_response_code(422);
exit('Unsupported file type');
}
Content inspection alone is not enough, because a valid image can carry PHP in its metadata and still be executed if the server is willing to run it. Block execution in the storage directory at the web server as well, which is covered in the walkthrough of handling file uploads safely in PHP.
Password storage
MD5 and SHA-1 were built to be fast, which is exactly the wrong property for a password hash. Consumer hardware computes them in enormous volume, so a stolen table of MD5 password hashes is a stolen table of passwords for anything in a common wordlist.
PHP has handled this correctly since version 5.5.
$hash = password_hash($password, PASSWORD_DEFAULT);
if (password_verify($password, $hash)) {
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$newHash = password_hash($password, PASSWORD_DEFAULT);
// persist $newHash against the user record
}
}
One correction worth making, because plenty of guides get it wrong: PASSWORD_DEFAULT is bcrypt, and has been for every release so far. It is not Argon2id. If you want Argon2id you have to ask for it by name with PASSWORD_ARGON2ID, and then you own the decision about memory and time cost parameters for your hardware. Bcrypt at a sensible cost factor remains a perfectly defensible choice.
The password_needs_rehash() check above is what lets you migrate a legacy application quietly. Users who log in get their password upgraded to the current algorithm without noticing, and after a few months you can force a reset for whoever is left.
Never write the password into a log or an exception trace. PHP 8.2 added an attribute for exactly this, which redacts the argument from stack traces.
function authenticate(string $email, #[\SensitiveParameter] string $password): bool
{
// ...
}
Session handling
Session hijacking means an attacker has a valid session identifier and can act as that user. Session fixation is the version where the attacker chooses the identifier beforehand and waits for the victim to authenticate into it.
Set the cookie flags in php.ini where you control it, or before session_start() where you do not.
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_secure', '1');
ini_set('session.use_strict_mode', '1');
ini_set('session.cookie_samesite', 'Lax');
session_start();
httponly keeps JavaScript away from the cookie, which removes the usual XSS-to-account-takeover route. secure restricts it to HTTPS. use_strict_mode makes PHP reject a session ID it did not issue, which is the setting that actually defeats fixation and is off by default.
SameSite=Lax suits most business sites and still allows normal inbound links to carry the session. Strict is safer and means a visitor arriving from a search result or an email appears logged out, so choose it deliberately rather than by copying a snippet.
Regenerate the identifier whenever the privilege level changes.
session_regenerate_id(true);
CSRF: requests forged through the visitor's own browser
The browser attaches your session cookie to any request aimed at your domain, including one triggered by a page on a different site. Without a token, the server cannot tell an intentional form submission from one an attacker caused.
Generate a token, put it in the form, and compare it in constant time when the request comes back.
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
printf(
'<input type="hidden" name="csrf" value="%s">',
htmlspecialchars($_SESSION['csrf'], ENT_QUOTES, 'UTF-8')
);
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(403);
exit('Invalid request token');
}
hash_equals() compares in constant time, so response timing gives away nothing about how much of the token was correct. A plain === comparison leaks that, slowly but measurably. Implementation detail for both the form and the verification side is in the guide to CSRF token protection in PHP.
SameSite cookies block a large share of CSRF on their own, and they are a useful second layer. They are not a replacement for tokens, because the protection varies with browser version and with how the request is triggered.
Deserialisation and remote code execution
PHP's unserialize() rebuilds objects from a string. Given control of that string, an attacker can assemble a chain of existing classes in your codebase whose destructors and magic methods, called in the right order, run code. Ready-made tooling for building those chains against common libraries has existed for years.
The rule is short: never call unserialize() on anything that came from outside the application.
// Reachable by an attacker
$data = unserialize($_POST['payload']);
// Structured data with no object construction
$data = json_decode($_POST['payload'], true, 512, JSON_THROW_ON_ERROR);
Where a legacy format genuinely has to be read, the second argument restricts what can be instantiated, and false blocks object construction entirely.
$data = unserialize($raw, ['allowed_classes' => false]);
The same caution applies to cookies and cached values that hold serialised PHP. Anything a user can edit is untrusted input, whatever it is named.
Keeping the stack patched
Application code is only part of the surface. An unsupported PHP branch stops receiving security fixes entirely, and a Composer dependency with a published advisory is a hole in your site regardless of how careful your own code is.
php -v
composer audit
composer outdated --direct
composer audit checks your installed versions against the packagist advisory database and needs no extra tooling. Run it in the deployment pipeline so a vulnerable dependency fails the build rather than being discovered later. Current support status for each PHP branch is published on php.net,, and the branch you run should be one that still gets fixes.
In production, errors belong in a log rather than on the page. display_errors off, log_errors on, and a real log destination. A stack trace rendered to the browser gives away file paths, framework versions and sometimes the contents of variables.
If the site is already compromised
Containment comes before investigation. Cleaning a live site while the attacker still has access means fixing things faster than they can put them back, which is not a contest you win.
- Take it offline or restrict access to your own address. Half-measures let the attacker watch what you are doing.
- Preserve evidence first. Copy the files, the database and the web server logs somewhere else before deleting anything, because that copy is the only record of how they got in.
- Find the entry point in the access log. Unusual query strings, POSTs to files that should not accept them, and requests for paths that did not exist last month are the usual signatures.
- Close the hole before restoring. Restoring a clean backup onto an unpatched application returns you to the same position within days.
- Rotate every credential. Database passwords, API keys, session secrets, SSH keys, mail credentials, anything that was readable from the server.
- Write down the timeline. Under UK GDPR, a personal data breach that meets the risk threshold has to reach the ICO within 72 hours of you becoming aware of it, and that clock does not wait for the cleanup.
Making this stick after the first fix
A one-off cleanup gets undone by the next feature. What keeps a PHP application secure is a small number of habits: user input never reaches a query as text, output is encoded for the context it lands in, uploads are inspected and stored outside the web root, and dependencies are checked on a schedule rather than when something breaks.
Review the code paths that build queries, generate HTML, handle uploads or read serialised data whenever they change. Those four are where nearly every PHP compromise starts, and they are short enough to look at properly. The wider PHP application security checklist covers the surrounding configuration.
If you want a second pair of eyes on a live PHP site, N. Cristea can review the application, the server configuration and the dependency tree, and come back with findings ranked by what an attacker could actually do with them. Get in touch with the PHP version, the framework or CMS, and a note on recent changes.