PHP Security Vulnerabilities That Still Cause Business Website Breaches
Most PHP security articles are written for developers who have not seen a breach. This one is written from the perspective of someone who has cleaned up after them. The vulnerabilities causing real-world breaches in business websites are not exotic. They are the same vulnerabilities documented for twenty years, still present in production code because the basics are not being followed consistently.
This article covers the security problems that appear repeatedly in PHP business websites: SQL injection, cross-site scripting, session security, file upload vulnerabilities, password handling, CSRF, and unsafe deserialisation. It is a practical checklist for the vulnerabilities that consistently appear in live applications, how they work, and how to check whether your site has them.
If you need help reviewing your current PHP setup, prepare a short note with your website URL, hosting details, current issue, and any recent changes before getting in touch.
SQL Injection: The Vulnerability That Still Dominates Breach Reports
SQL injection has been documented since 1998. The OWASP Top 10 has listed it in every edition since the list began. It is understood better than almost any other web vulnerability. It remains the most common root cause of PHP website breaches. The reason is not that the fix is unknown. The reason is that PHP applications consistently build SQL queries by concatenating user input without proper escaping.
The mechanism is straightforward. When user input is included in a SQL query without escaping, an attacker can alter the query's logic. A normal query might retrieve user data based on an email address. An attacker submitting carefully crafted input can change the query's structure entirely, returning data the developer never intended to expose.
The underlying vulnerability applies to any unescaped SQL query in PHP, regardless of the framework or application type. Understanding where this vulnerability sits alongside other common web application risks helps with prioritisation decisions. The OWASP Top 10 for business web applications provides a useful overview of the broader security landscape you may encounter in your PHP codebase.
In PHP specifically, the most common SQL injection vector is direct use of $_POST or $_GET values in query strings without any escaping. Functions like mysqli_real_escape_string() exist specifically to handle this, but developers often use them incorrectly or forget them entirely on some input paths while applying them correctly on others.
Consider a typical PHP login handler that builds a query by concatenating user input directly:
$email = $_POST['email'];
$password = $_POST['password'];
$query = "SELECT * FROM users WHERE email = '$email' AND password = '$password'";
$result = mysqli_query($conn, $query);
If an attacker submits ' OR '1'='1 as the email address, the query becomes:
SELECT * FROM users WHERE email = '' OR '1'='1' AND password = ''
The OR '1'='1' condition is always true, so the query returns the first user in the table. If that first account has administrator privileges, the attacker gains full access to your application.
The correct approach uses prepared statements, which separate query structure from user data and make injection structurally impossible regardless of what input is supplied:
$stmt = $conn->prepare("SELECT * FROM users WHERE email = ? AND password = ?");
$stmt->bind_param("ss", $_POST['email'], $_POST['password']);
$stmt->execute();
With prepared statements, user input is never interpreted as SQL code. This is the only safe approach. Manual escaping functions can work as a belt-and-braces measure but should never be the primary defence because it is easy to miss on some input paths while applying them correctly on others.
Blind SQL injection is a variant that applies when the application does not return query results visibly. An attacker determines whether a condition is true or false based on how long the page takes to load or whether the response changes. If your application has SQL injection vulnerabilities, automated tools can detect and exploit them in minutes.
Cross-Site Scripting: Stealing Sessions and Defacing Pages
Cross-site scripting (XSS) occurs when user input is reflected in a web page without proper encoding, allowing an attacker to inject JavaScript that runs in the victim's browser. Unlike SQL injection, which attacks the server, XSS attacks the users of your site. Both are common in PHP applications that do not encode output correctly before sending it to the browser.
Stored XSS is the most dangerous variant. User input is saved to a database and served to all visitors without encoding. A common scenario is a comment form that does not sanitise HTML. If an attacker submits a comment containing a script tag, every visitor who loads that comment page executes the attacker's JavaScript in their browser. This JavaScript can steal session cookies, capture keystrokes, or modify page content.
Reflected XSS occurs when user input is echoed back in a response without encoding. The classic example is a search result page that displays the search term without escaping it. An attacker crafts a URL containing a script and tricks a victim into clicking it. The script then executes in the victim's browser with the same privileges as your legitimate pages.
DOM-based XSS is more subtle. The attack payload never reaches the server. Instead, JavaScript on the page reads user input from the URL fragment or other sources and writes it to the page DOM without sanitisation. Traditional server-side filters miss this variant entirely.
The fix for output is context-appropriate encoding. HTML output must escape special characters. PHP's htmlspecialchars() function handles this reliably when used correctly:
echo htmlspecialchars($userInput, ENT_QUOTES | ENT_HTML5, 'UTF-8');
The flags ENT_QUOTES | ENT_HTML5 ensure both single and double quotes are encoded and that the output uses proper HTML5 entity syntax. Always specify the character encoding explicitly. Content Security Policy (CSP) headers provide a secondary defence layer. A correctly configured CSP prevents inline scripts from executing even if an XSS vulnerability exists:
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-random123'");
For a practical guide specifically covering input validation and output encoding techniques, the PHP security checklist walks through different contexts where user input appears in your pages.
File Upload Vulnerabilities: When Your Upload Form Executes Code
File upload forms are common in business websites: profile photo uploads, document submissions, image galleries, and support ticket attachments. They are also one of the most frequently exploited attack surfaces in PHP applications. A misconfigured file upload handler can allow an attacker to upload and execute arbitrary PHP code on your server.
The attack works like this: an attacker uploads a PHP file instead of an image. If the upload process only checks the file extension or the MIME type sent by the browser, the file is saved with a .php extension. The attacker then visits the uploaded file's URL, and the server executes it as PHP code. At that point the attacker has full access to the server, including database credentials, file system, and any other applications running on the same host.
The correct approach to file uploads involves multiple checks, none of which alone is sufficient:
- Validate file type server-side: Check the actual file content, not just the extension or MIME type. Use PHP's
finfo_file()function to read the file's magic bytes and determine its true type. - Store outside web root: Uploaded files should not be directly accessible via a URL. Keep them in a directory that is not served by the web server.
- Generate random filenames: Never use the user-provided filename for storage. A collision-resistant random string prevents filename collisions and hides the original filename.
- Use a delivery script: Serve files through PHP with proper access control checks. Validate that the requesting user has permission before reading and outputting the file content.
A simple server-side MIME type check using PHP's fileinfo extension looks like this:
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $tmpFilePath);
finfo_close($finfo);
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (!in_array($mimeType, $allowedTypes, true)) {
// Reject the upload
}
Password Handling: Why MD5 Is Still Being Used in Production
Password storage failures have caused some of the largest breaches in history. The problem is straightforward: many PHP applications still use hash functions designed for data integrity verification, not password storage. MD5 and SHA-1 are fast hash functions. Modern GPUs can compute billions of MD5 hashes per second. A list of one million common passwords can be matched against an entire password database in seconds.
PHP's password_hash() function uses bcrypt by default and automatically handles salt generation. The salt is built into the hash output, so you do not need to manage it separately:
$hash = password_hash($password, PASSWORD_DEFAULT);
if (password_verify($password, $hash)) {
// Login successful
}
The PASSWORD_DEFAULT constant uses the current strongest algorithm supported by PHP (currently Argon2id). When PHP upgrades its default algorithm in a future release, existing hashes can be automatically upgraded by checking password_needs_rehash() on each login:
if (password_verify($password, $hash)) {
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$newHash = password_hash($password, PASSWORD_DEFAULT);
// Update hash in database
}
return true;
}
If you are maintaining a legacy application that currently uses MD5 or SHA-1 for password storage, migrate to password_hash() as soon as possible. Existing users can be prompted to change their password on next login, or you can rehash on successful login as shown above.
Session Security: Hijacking and Fixation
Session hijacking occurs when an attacker obtains a valid session identifier and uses it to impersonate the associated user. In PHP, session identifiers are typically stored in a cookie named PHPSESSID. Session fixation is a related attack where the attacker sets the session identifier before the victim logs in, hoping the application will adopt it after authentication.
Configure session security settings in php.ini or at runtime:
ini_set('session.cookie_httponly', 1); // JavaScript cannot read the session cookie
ini_set('session.cookie_secure', 1); // Cookie only sent over HTTPS
ini_set('session.use_strict_mode', 1); // Refuse unrecognised session IDs
ini_set('session.cookie_samesite', 'Strict'); // Cookie not sent in cross-site requests
The httponly flag prevents JavaScript from accessing the session cookie, which blocks the most common XSS-based session theft vector. The secure flag ensures the cookie is only transmitted over encrypted connections. The samesite flag prevents the cookie from being sent with cross-site requests, which mitigates CSRF attacks and prevents the cookie from leaking during navigation to external sites.
Regenerate the session ID after any privilege change such as login or logout:
session_regenerate_id(true); // true destroys the old session
The true parameter destroys the old session data, preventing session fixation attacks where an attacker tries to reuse a known session ID.
CSRF: Forging Requests on Behalf of Your Users
Cross-site request forgery (CSRF) tricks a logged-in user into submitting a request they did not intend. Because the user's browser automatically includes their session cookie with every request to your domain, the forged request is authenticated by virtue of the victim's existing session. The server has no way to distinguish a legitimate form submission from a forged one unless you explicitly add a CSRF token.
The classic attack scenario: a user is logged into your application in one browser tab while browsing a malicious website in another tab. The malicious page contains an auto-submitting form that POSTs to your application's endpoints, such as changing the account email address or transferring funds. CSRF tokens prevent this. Every state-changing request should include a unique, unpredictable token that the server verifies before processing.
For a practical implementation guide covering CSRF token generation and verification in PHP, there are detailed code examples for both form implementation and server-side validation.
The token generation and verification pattern looks like this:
// Generate token when showing the form
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
echo '';
// Verify token when processing the form
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
http_response_code(403);
exit('Invalid CSRF token');
}
The hash_equals() function provides timing-safe string comparison, which prevents timing attacks that could theoretically allow an attacker to guess the token value by measuring response times.
Remote Code Execution: When Deserialisation Becomes a Weapon
PHP's unserialize() function is a direct path to remote code execution. It takes a string and reconstructs a PHP object from it. If an attacker can control the input to unserialize(), they can craft a payload that exploits PHP's object autoloading mechanism to execute arbitrary code on your server.
This is not a theoretical vulnerability. Exploitation tools for PHP deserialisation have been publicly available for years, and automated scanners can identify vulnerable endpoints quickly. The fix is simple but requires attention throughout your codebase.
Never use unserialize() on data that comes from untrusted sources. Use JSON instead, which does not support object deserialisation. If you must handle serialised PHP data, use json_encode() and json_decode() throughout your application:
// Unsafe - never use on untrusted input
$data = unserialize($_POST['serialized_data']);
// Safe alternative
$data = json_decode($_POST['json_data'], true);
If your application uses a PHP framework, check whether it provides safe deserialisation options. Many modern frameworks have deprecated or restricted the use of PHP's native serialisation functions.
What to Do If Your PHP Site Is Already Compromised
If you discover a breach, the priority is containment before investigation. Take the site offline immediately by blocking all traffic or taking the server down. Do not try to clean and keep it live while investigating. An attacker with persistent access will simply re-enter through a backdoor you missed during the cleanup.
The following steps represent a systematic approach to incident response for a compromised PHP application:
- Take the site offline immediately: Block all traffic or take the server down. Do not attempt to clean while keeping it live. An attacker watching your cleanup efforts will adapt faster than you can respond.
- Audit access logs: Understand how the attacker gained access. Identify the exploited vulnerability by examining the attack pattern in your logs. Look for requests with unusual parameters, unexpected file access, or commands that should not appear in web server logs.
- Fix the vulnerability before restoring: Do not bring the site back online until the entry point is closed. If you restore without fixing the root cause, the breach will happen again.
- Restore from a known-good backup: Use a backup that was taken before the breach date. Verify that the backup itself is clean before restoring. If your backups are also compromised, you may need to rebuild from source code and a clean database schema.
- Change all credentials: Database passwords, API keys, session secrets, SSH keys, and any credentials stored on the server should be rotated immediately after restore.
- Document everything: Record the timeline, the vulnerability exploited, the data potentially accessed, and the steps taken to remediate. If the breach involved customer data, legal obligations under GDPR apply. The ICO expects evidence of a thorough investigation and remediation.
If you are dealing with an active breach and need guidance on the specific vulnerabilities in your application, document what you have observed and get in touch with details of your setup before making any changes to the server.
Building a Sustainable PHP Security Practice
Security is not a one-time fix. PHP applications require ongoing attention as new vulnerabilities emerge and your codebase evolves. A single security review is useful but insufficient if the development process does not maintain the protections over time.
Regular code reviews focusing on how user input flows through your application help catch issues before they reach production. Pay particular attention to any code that builds database queries, generates HTML output, handles file uploads, or processes serialised data.
Keep your PHP version current. Each minor release includes security patches, and running an unsupported version means you are missing fixes for known vulnerabilities. The same applies to libraries and frameworks in your stack. Subscribe to security advisories for every dependency you use and apply patches promptly.
Before deploying any PHP application that handles user data or authentication, run through the specific vulnerabilities covered in this article. SQL injection, XSS, CSRF, weak session configuration, unsafe deserialisation, and poor file upload handling represent the most common paths attackers use to compromise PHP business websites. A thorough review of these areas before launch catches the vulnerabilities that automated scanners will find anyway, and fixing them proactively is far less disruptive than responding to a breach.