Contents
- 1 Common PHP security vulnerabilities
- 1.1 Why PHP security still trips us up
- 1.2 SQL injection: The classic data heist
- 1.3 Cross-site scripting (XSS): Scripts in the shadows
- 1.4 File inclusion attacks: Loading the wrong files
- 1.5 Remote code execution (RCE): Code from nowhere
- 1.6 Session hijacking: Impersonation blues
- 1.7 Command injection and CSRF: Hidden chains
- 1.8 Secure file uploads: No backdoors, please
- 1.9 Passwords and crypto: Hash it right
- 1.10 Config hardening: The foundation
- 1.11 Tools and habits for the win
Common PHP security vulnerabilities
Hey, fellow developers. Picture this: it's 2 AM, your coffee's gone cold, and that login form you've been tweaking all week suddenly feels like a ticking bomb. One wrong move with user input, and bam—someone's rifling through your database. I've been there. That sinking gut feeling when a vulnerability slips through. PHP powers so much of the web—WordPress sites, e-commerce giants, internal tools we all rely on. But with great power comes the usual suspects: security holes that hackers love.
We're not talking abstract threats here. These are real, everyday vulnerabilities that hit PHP apps hard. SQL injections that dump user data. XSS scripts stealing sessions. File uploads turning into backdoors. I've lost nights to these, and fixed even more. Let's walk through the most common ones, why they bite, and how to actually plug them. No fluff. Just code, configs, and hard-won lessons.
Why PHP security still trips us up
PHP's been around forever—battle-tested, flexible as hell. But that flexibility? It's a double-edged sword. Dynamic typing, easy string concatenation, those handy functions like eval() or include(). They make prototyping fast, but leave doors wide open if you're not careful.
Remember that time a client's forum got pwned because of a lazy query? I do. Data everywhere, trust shattered. Stats don't lie: OWASP's top ten lists injection and XSS as perennials. In 2026, with PHP 8.3+ pushing attributes and enums, we're safer out of the box. But legacy code, third-party deps via Composer, misconfigs—they keep the risks alive.
The fix? Layered defense. Start with php.ini tweaks, embrace prepared statements, validate everything. And test like your repo's on fire.
SQL injection: The classic data heist
You know the drill. User types ' OR 1=1 -- into a login field. Suddenly, your query becomes SELECT * FROM users WHERE username='' OR 1=1 --' AND password=.... Every row dumps out. Boom, unauthorized access.
I chased one of these ghosts for hours once. The app concatenated user input straight into a query. Simple fix? Prepared statements. PDO or MySQLi. Here's the before-and-after:
Vulnerable:
$email = $_POST['email'];
$query = "SELECT * FROM users WHERE email = '$email'";
$result = mysqli_query($conn, $query);
Secure:
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $_POST['email']]);
PDO separates SQL structure from data. No injection possible. But don't stop there. Validate input with filter_var():
$email = filter_var($_POST['email'], FILTER_VALIDATE_EMAIL);
if (!$email) {
http_response_code(400);
exit('Invalid email');
}
Pro tip: Whitelist expected types. Emails? FILTER_VALIDATE_EMAIL. Integers? FILTER_VALIDATE_INT. Even with preps, bad data crashes apps.
Have you audited your queries lately? That old mysql_* cruft still lurking?
Cross-site scripting (XSS): Scripts in the shadows
XSS is sneaky. User posts <script>alert('pwned');</script> in a comment. It runs for everyone viewing the page. Steals cookies, hijacks sessions, redirects to phishing sites.
There are flavors: reflected (GET params), stored (DB-saved), DOM-based (JS manip). Stored ones hurt most—persistent pain.
Output escaping is your shield. Use htmlspecialchars() everywhere dynamic data hits HTML:
echo htmlspecialchars($userComment, ENT_QUOTES, 'UTF-8');
For JS contexts? json_encode() or libraries like Twig's auto-escape.
Headers amp it up. Add Content Security Policy (CSP):
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-randomNonce';
Set via .htaccess or Nginx:
add_header Content-Security-Policy "default-src 'self';";
Input side: Sanitize with filter_var(FILTER_SANITIZE_STRING) or HTMLPurifier for heavy lifting.
I once forgot to escape a profile bio. Next day, fake login popups everywhere. Lesson: Escape on output, always.
File inclusion attacks: Loading the wrong files
include($_GET['page'] . '.php');. Harmless prototype? Attacker swaps page=../../../etc/passwd. Local File Inclusion (LFI) reads sensitive files. If allow_url_include=On, Remote File Inclusion (RFI) pulls malware from afar.
Disable in php.ini:
allow_url_fopen = Off
allow_url_include = Off
open_basedir = /var/www/html
Whitelist paths:
$pages = ['home', 'about', 'contact'];
$page = basename($_GET['page'] ?? 'home');
if (!in_array($page, $pages)) {
$page = 'home';
}
include "pages/{$page}.php";
basename() kills path traversal. Store sessions outside webroot too: session.save_path = /var/lib/php/sessions.
File uploads compound this. See below.
Remote code execution (RCE): Code from nowhere
eval($_POST['code']);. Don't. Ever. But it happens in deserializers, unserialize hacks, or system($_GET['cmd']).
Kill dangerous functions:
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source
Strict input validation. If you must exec, escape:
$cmd = escapeshellcmd('/safe/script.sh');
$arg = escapeshellarg($userInput);
exec("{$cmd} {$arg}", $output);
Composer deps? Scan with composer audit. Update religiously: composer update --with-dependencies.
One RCE via a rogue plugin cost a project weeks. Disable, validate, scan.
Session hijacking: Impersonation blues
Sessions leak via URLs (?sid=abc123), weak IDs, or XSS. Attacker grabs cookie, logs in as you.
Secure configs:
session.cookie_httponly = 1
session.cookie_secure = 1
session.use_strict_mode = 1
session.use_only_cookies = 1
Regen on login:
session_start();
session_regenerate_id(true);
Store in Redis/Memcached for scale, not files.
Add HSTS header: Strict-Transport-Security: max-age=31536000; includeSubDomains.
Command injection and CSRF: Hidden chains
Command injection: system("ls " . $_GET['dir']);. Attacker: ; rm -rf /.
Whitelist + escape:
$allowedDirs = ['/tmp', '/var/log'];
$dir = escapeshellarg(in_array($_GET['dir'], $allowedDirs) ? $_GET['dir'] : '/tmp');
system("ls {$dir}");
CSRF: Tricks logged-in users into actions. Tokens fix it:
session_start();
$token = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $token;
echo "<input type='hidden' name='csrf' value='{$token}'>";
Verify on POST:
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf'])) {
exit('CSRF fail');
}
Secure file uploads: No backdoors, please
Uploads are goldmines for exploits. PHP webshell disguised as photo.jpg.
Checklist:
- Authenticate users first.
- Whitelist MIME + extension:
image/jpeg,jpg. move_uploaded_file()only.- Limit size:
upload_max_filesize=2M. - Store outside webroot.
- Scan with ClamAV or similar.
Code:
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
exit('Upload failed');
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['file']['tmp_name']);
$allowed = ['image/jpeg', 'image/png'];
if (!in_array($mime, $allowed)) {
exit('Invalid type');
}
$ext = pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION);
$newName = uniqid() . '.' . $ext;
move_uploaded_file($_FILES['file']['tmp_name'], "/secure/uploads/{$newName}");
.htaccess blocks execution:
<Files "*.php">
Order Allow,Deny
Deny from all
</Files>
Passwords and crypto: Hash it right
MD5? SHA1? Trash. Use password_hash():
$hash = password_hash($password, PASSWORD_ARGON2ID);
if (password_verify($password, $hash)) {
// Login
}
Argon2id for 2026—memory-hard, resists GPUs.
Config hardening: The foundation
php.ini essentials:
display_errors = Off
log_errors = On
expose_php = Off
memory_limit = 256M
max_execution_time = 30
Error logging: error_log = /var/log/php_errors.log.
Update PHP: 8.3+ has JIT hardening. Composer: composer outdated.
Tools and habits for the win
- Static analysis: Psalm, PHPStan.
- Dynamic: OWASP ZAP, Burp Suite.
- Deps: Snyk, Dependabot.
- Audit:
phpcs --standard=PSR12+ security rulesets.
Run composer require --dev squizlabs/php_codesniffer and config security standards.
I've integrated Psalm into CI. Catches 80% of issues pre-deploy.
Friends, these vulnerabilities aren't inevitable. They're choices. Late nights debugging breaches? Avoidable. Next time you're concatenating strings or trusting uploads, pause. Lock it down. Your users, your sanity—worth it.
That quiet satisfaction of a fortified app? It lingers. Build secure, sleep sound.