PHP Security: XSS, CSRF, Injection & Auth
PHP applications are high-value targets because so much of the web runs PHP. Most vulnerabilities fall into a handful of categories — understanding each one and its mitigation is essential for any PHP developer.
XSS — Cross-Site Scripting
<?php
// XSS: attacker injects <script> that runs in victim's browser
// NEVER do this:
echo $_GET['name']; // raw user input in HTML
echo "<b>" . $_POST['msg'] . "</b>";
// ALWAYS escape output with the correct context function:
// HTML context — htmlspecialchars (converts < > " ' & to entities)
echo htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// Helper function
function e(string $value): string {
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
echo "<p>Hello, " . e($_GET['name']) . "</p>";
// HTML attribute context
echo '<input value="' . e($value) . '">';
// URL context
echo '<a href="' . e(urlencode($path)) . '">Link</a>';
// JavaScript context — use json_encode
echo '<script>var data = ' . json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP) . ';</script>';
// Content Security Policy header (defense-in-depth)
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'");CSRF — Cross-Site Request Forgery
<?php
// CSRF: malicious site tricks authenticated user into submitting a form to your app
// 1. Generate token (store in session)
session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// 2. Embed in every state-changing form
?>
<form method="POST" action="/transfer">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="text" name="amount">
<button type="submit">Transfer</button>
</form>
<?php
// 3. Validate on every POST/PUT/DELETE
function validateCsrfToken(): void {
session_start();
$token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
http_response_code(403);
die('Invalid CSRF token');
}
}
// SameSite cookie attribute (additional mitigation)
session_set_cookie_params([
'samesite' => 'Lax', // or 'Strict'
'secure' => true,
'httponly' => true,
]);SQL Injection & File Upload
<?php
// SQL Injection — always use prepared statements (see PDO section)
// Bad:
$id = $_GET['id'];
$result = $pdo->query("SELECT * FROM users WHERE id = $id"); // NEVER
// Good: prepared statement with bound parameter (see page 2)
// Command injection — never pass user input to shell
// Bad:
system("convert " . $_POST['filename'] . " output.jpg"); // NEVER
// Good: validate strictly, use escapeshellarg if unavoidable
$filename = basename($_POST['filename']);
if (!preg_match('/^[a-zA-Z0-9_.-]+$/', $filename)) {
die('Invalid filename');
}
system("convert " . escapeshellarg("/uploads/" . $filename) . " output.jpg");
// File upload security
function handleUpload(array $file): string {
// 1. Check for upload errors
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new \RuntimeException('Upload error');
}
// 2. Validate real MIME type (not Content-Type header — easily spoofed)
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
$allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!in_array($mimeType, $allowed, true)) {
throw new \RuntimeException('Invalid file type');
}
// 3. Validate size
if ($file['size'] > 5 * 1024 * 1024) {
throw new \RuntimeException('File too large');
}
// 4. Generate safe filename (never trust original name)
$ext = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'][$mimeType];
$newName = bin2hex(random_bytes(16)) . '.' . $ext;
// 5. Store outside webroot or use randomized path
$dest = '/var/app/uploads/' . $newName;
move_uploaded_file($file['tmp_name'], $dest);
return $newName;
}Password Hashing & Session Security
<?php
// Password hashing — bcrypt (cost 12+ recommended)
$hash = password_hash($plaintext, PASSWORD_BCRYPT, ['cost' => 12]);
// or: PASSWORD_ARGON2ID (more secure, requires libsodium)
$hash = password_hash($plaintext, PASSWORD_ARGON2ID);
// Verify
if (password_verify($plaintext, $hash)) {
// authenticated
// Rehash if algorithm/cost changed
if (password_needs_rehash($hash, PASSWORD_ARGON2ID)) {
$newHash = password_hash($plaintext, PASSWORD_ARGON2ID);
// save $newHash to DB
}
}
// Cryptographically secure random values
$token = bin2hex(random_bytes(32)); // 64-char hex token
$otp = random_int(100000, 999999); // 6-digit OTP
// Constant-time comparison (prevents timing attacks)
hash_equals($expectedToken, $submittedToken); // never use ===
// Secure session configuration (php.ini or ini_set)
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.use_strict_mode', 1); // reject uninitialized session IDs
ini_set('session.gc_maxlifetime', 3600);
session_start();
// Regenerate ID after privilege change (login, sudo)
session_regenerate_id(delete_old_session: true);Security Headers & Tips
Set security headers: Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy.
Keep PHP version current — EOL versions receive no security patches. Check php.net/supported-versions.
Disable dangerous php.ini settings: expose_php=Off, display_errors=Off (in production), allow_url_fopen=Off.
Use HTTPS everywhere. Redirect HTTP → HTTPS at the web server level, not PHP.
Rate-limit authentication endpoints to prevent brute-force. Log failed attempts.
Store secrets in environment variables (never in code or DB). Use phpdotenv in development.
Use a security scanning tool: Psalm taint analysis, RIPS, or SonarQube for static analysis.
Keep your own version of these notes — editable, searchable, and organised by your stack.
Start free