
Chapter 18: Security Best Practices
Advanced 120-150 minOverview
Security is not optional—it's fundamental to building production-ready PHP applications. As a Java developer transitioning to PHP, you're already familiar with security concepts like input validation, SQL injection prevention, and authentication. This chapter translates those concepts into PHP-specific implementations, covering the OWASP Top 10 vulnerabilities and providing practical, battle-tested solutions.
Security vulnerabilities can compromise user data, damage your reputation, and even end careers. Unlike Java's Spring Security framework that provides many security features out of the box, PHP requires you to implement security measures explicitly. This chapter teaches you how to build secure PHP applications from the ground up, covering everything from SQL injection prevention to secure file uploads.
What You'll Learn:
- OWASP Top 10 vulnerabilities and PHP-specific mitigations
- SQL injection prevention with prepared statements
- Cross-Site Scripting (XSS) protection strategies
- Cross-Site Request Forgery (CSRF) token implementation
- Secure password hashing and authentication
- Security headers configuration
- File upload security best practices
- Input validation and sanitization techniques
- Secure session management
- Error handling without information disclosure
Prerequisites
Time Estimate
⏱️ 120-150 minutes to complete this chapter
Before starting this chapter, you should be comfortable with:
- Database operations with PDO (Chapter 9)
- Form handling and validation (Chapter 17)
- Session management and authentication (Chapter 16)
- HTTP fundamentals (headers, methods, status codes)
- Basic understanding of web security concepts
Verify your setup:
# Check PHP version (8.4+ recommended)
php --version
# Verify PDO extension is available
php -m | grep pdoLearning Objectives
By the end of this chapter, you will be able to:
- Prevent SQL injection using prepared statements and parameterized queries
- Protect against XSS attacks with proper output escaping and Content Security Policy
- Implement CSRF protection with secure token generation and validation
- Secure authentication with strong password hashing and rate limiting
- Implement authorization with RBAC, permissions, and resource access control
- Prevent command injection using safe command execution methods
- Prevent XXE attacks by disabling external entity processing
- Avoid deserialization vulnerabilities by using JSON or whitelisting classes
- Prevent mass assignment by whitelisting allowed fields
- Prevent IDOR vulnerabilities by validating resource access
- Manage configuration securely using environment variables and secret storage
- Scan dependencies for vulnerabilities using Composer audit
- Encrypt sensitive data at rest using sodium encryption
- Log security events and detect intrusion patterns
- Configure security headers to protect against common attack vectors
- Handle file uploads securely with content validation and safe storage
- Validate and sanitize input using PHP's filter functions
- Manage sessions securely with proper configuration and regeneration
- Handle errors safely without exposing sensitive information
- Apply OWASP Top 10 mitigations to your PHP applications
What You'll Build
By the end of this chapter, you will have created:
- A secure authentication system with password hashing and rate limiting
- An authorization system with RBAC and permission-based access control
- A CSRF protection middleware class
- A security headers configuration class
- A secure file upload handler with content validation
- Input validation and sanitization utilities
- Safe command execution utilities
- Secure XML parsing with XXE prevention
- Mass assignment protection utilities
- IDOR prevention with resource access validation
- Secure configuration management with environment variables
- Dependency vulnerability scanning tools
- Encryption utilities for data at rest
- Security logging and intrusion detection system
- A comprehensive security checklist for your applications
Section 1: Understanding OWASP Top 10
The OWASP Top 10 is a standard awareness document representing the most critical security risks to web applications. Understanding these vulnerabilities helps you build more secure applications.
OWASP Top 10 Overview
1. Broken Access Control
- Unauthorized access to resources
- PHP mitigation: Implement proper authorization checks
2. Cryptographic Failures
- Weak encryption or exposed sensitive data
- PHP mitigation: Use
password_hash()with strong algorithms
3. Injection
- SQL, NoSQL, Command injection
- PHP mitigation: Prepared statements, parameterized queries
4. Insecure Design
- Flawed security architecture
- PHP mitigation: Security-first design principles
5. Security Misconfiguration
- Default configurations, exposed files
- PHP mitigation: Secure defaults, proper headers
6. Vulnerable Components
- Outdated dependencies
- PHP mitigation: Keep Composer packages updated
7. Authentication Failures
- Weak authentication mechanisms
- PHP mitigation: Strong password hashing, rate limiting
8. Software and Data Integrity Failures
- Unsafe CI/CD, untrusted sources
- PHP mitigation: Verify dependencies, use checksums
9. Security Logging Failures
- Insufficient logging and monitoring
- PHP mitigation: Comprehensive error logging
10. Server-Side Request Forgery (SSRF)
- Forced server requests to internal resources
- PHP mitigation: Validate and sanitize URLs
Section 2: SQL Injection Prevention
SQL injection occurs when attackers manipulate SQL queries by injecting malicious SQL code through user input. This is one of the most dangerous vulnerabilities and must be prevented at all costs.
The Problem: String Concatenation
<?php
declare(strict_types=1);
// ❌ DANGEROUS: Direct string concatenation
$email = $_GET['email'] ?? '';
$sql = "SELECT * FROM users WHERE email = '$email'";
$result = $pdo->query($sql);
// Attacker can inject: email=' OR '1'='1
// Results in: SELECT * FROM users WHERE email = '' OR '1'='1'
// This returns ALL users!The Solution: Prepared Statements
<?php
declare(strict_types=1);
namespace App\Security;
use PDO;
use PDOException;
class SecureDatabase
{
public function __construct(
private PDO $pdo
) {}
/**
* ✅ SAFE: Use prepared statements with parameters
*/
public function findUserByEmail(string $email): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, email, name FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
return $user ?: null;
}
/**
* ✅ SAFE: Multiple parameters
*/
public function findUsersByRoleAndStatus(
string $role,
bool $active
): array {
$stmt = $this->pdo->prepare(
'SELECT * FROM users WHERE role = :role AND active = :active'
);
$stmt->execute([
'role' => $role,
'active' => $active ? 1 : 0
]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* ✅ SAFE: INSERT with prepared statements
*/
public function createUser(
string $email,
string $name,
string $passwordHash
): int {
$stmt = $this->pdo->prepare(
'INSERT INTO users (email, name, password_hash, created_at)
VALUES (:email, :name, :password_hash, NOW())'
);
$stmt->execute([
'email' => $email,
'name' => $name,
'password_hash' => $passwordHash
]);
return (int) $this->pdo->lastInsertId();
}
/**
* ✅ SAFE: LIKE queries with prepared statements
*/
public function searchUsers(string $searchTerm): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM users WHERE name LIKE :search'
);
// Escape wildcards in search term
$searchTerm = str_replace(['%', '_'], ['\%', '\_'], $searchTerm);
$stmt->execute([
'search' => "%{$searchTerm}%"
]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}Positional vs Named Parameters
<?php
declare(strict_types=1);
// Positional parameters (use ?)
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ? AND role = ?');
$stmt->execute([$email, $role]);
// Named parameters (use :name)
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email AND role = :role');
$stmt->execute(['email' => $email, 'role' => $role]);
// Named parameters are more readable and maintainableDynamic WHERE Clauses
<?php
declare(strict_types=1);
namespace App\Security;
class UserRepository
{
public function __construct(
private \PDO $pdo
) {}
/**
* ✅ SAFE: Build dynamic queries with prepared statements
*/
public function findUsers(array $filters): array
{
$conditions = [];
$params = [];
if (isset($filters['email'])) {
$conditions[] = 'email = :email';
$params['email'] = $filters['email'];
}
if (isset($filters['role'])) {
$conditions[] = 'role = :role';
$params['role'] = $filters['role'];
}
if (isset($filters['active'])) {
$conditions[] = 'active = :active';
$params['active'] = $filters['active'] ? 1 : 0;
}
$where = !empty($conditions)
? 'WHERE ' . implode(' AND ', $conditions)
: '';
$sql = "SELECT * FROM users {$where}";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
}Why Prepared Statements Work
Prepared statements separate SQL logic from data:
- SQL structure is parsed first - The database understands the query structure
- Parameters are bound separately - Data is treated as data, not SQL code
- Type safety - Parameters are properly typed and escaped
- Performance - Queries can be cached and reused
Section 3: Cross-Site Scripting (XSS) Protection
XSS attacks occur when malicious scripts are injected into web pages viewed by other users. PHP applications must escape all output to prevent XSS.
Types of XSS Attacks
1. Stored XSS - Malicious script stored in database 2. Reflected XSS - Malicious script reflected in response 3. DOM-based XSS - Client-side script manipulation
Output Escaping
<?php
declare(strict_types=1);
namespace App\Security;
class XSSProtection
{
/**
* ✅ Escape HTML output
*/
public static function escapeHtml(string $input): string
{
return htmlspecialchars(
$input,
ENT_QUOTES | ENT_HTML5,
'UTF-8'
);
}
/**
* ✅ Escape HTML attributes
*/
public static function escapeAttribute(string $input): string
{
return htmlspecialchars(
$input,
ENT_QUOTES,
'UTF-8',
false
);
}
/**
* ✅ Escape JavaScript strings
*/
public static function escapeJavaScript(string $input): string
{
return json_encode($input, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
}
/**
* ✅ Escape URL parameters
*/
public static function escapeUrl(string $input): string
{
return urlencode($input);
}
}Context-Aware Escaping
<?php
declare(strict_types=1);
// HTML context
echo '<div>' . htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8') . '</div>';
// HTML attribute context
echo '<input value="' . htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8') . '">';
// JavaScript context
echo '<script>var name = ' . json_encode($userInput, JSON_HEX_TAG) . ';</script>';
// URL context
echo '<a href="/user/' . urlencode($userId) . '">Profile</a>';
// CSS context (rare, but important)
echo '<style>color: ' . preg_replace('/[^a-zA-Z0-9#]/', '', $color) . ';</style>';Content Security Policy (CSP)
<?php
declare(strict_types=1);
namespace App\Security;
class SecurityHeaders
{
/**
* Set Content Security Policy header
*/
public static function setCSP(string $policy = "default-src 'self'"): void
{
header("Content-Security-Policy: {$policy}");
}
/**
* Common CSP policies
*/
public static function setStrictCSP(): void
{
$policy = implode('; ', [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'", // Remove 'unsafe-inline' in production
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'"
]);
self::setCSP($policy);
}
}
// Usage
SecurityHeaders::setStrictCSP();Template Helper Function
<?php
declare(strict_types=1);
// Create a helper function for templates
function e(string $string): string
{
return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
// Usage in templates
?>
<div class="user-name"><?= e($user['name']) ?></div>
<input type="text" value="<?= e($user['email']) ?>">
<a href="/user/<?= e($userId) ?>">View Profile</a>Section 4: Cross-Site Request Forgery (CSRF) Protection
CSRF attacks trick authenticated users into executing unwanted actions. CSRF tokens ensure requests originate from your application.
CSRF Token Implementation
<?php
declare(strict_types=1);
namespace App\Security;
class CSRFProtection
{
/**
* Generate CSRF token
*/
public static function generateToken(): string
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
/**
* Validate CSRF token
*/
public static function validateToken(string $token): bool
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['csrf_token'])) {
return false;
}
// Use hash_equals to prevent timing attacks
return hash_equals($_SESSION['csrf_token'], $token);
}
/**
* Get token from request
*/
public static function getTokenFromRequest(): ?string
{
return $_POST['csrf_token'] ?? $_GET['csrf_token'] ?? null;
}
/**
* Require valid CSRF token or throw exception
*/
public static function requireToken(): void
{
$token = self::getTokenFromRequest();
if ($token === null || !self::validateToken($token)) {
http_response_code(403);
throw new \RuntimeException('CSRF token validation failed');
}
}
}Using CSRF Tokens in Forms
<?php
declare(strict_types=1);
require_once __DIR__ . '/Security/CSRFProtection.php';
use App\Security\CSRFProtection;
// Generate token for form
$csrfToken = CSRFProtection::generateToken();
?>
<!DOCTYPE html>
<html>
<head>
<title>Create User</title>
</head>
<body>
<form method="POST" action="/users/create">
<!-- Include CSRF token -->
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
<input type="text" name="name" placeholder="Name" required>
<input type="email" name="email" placeholder="Email" required>
<button type="submit">Create User</button>
</form>
</body>
</html>Validating CSRF Tokens on Form Submission
<?php
declare(strict_types=1);
require_once __DIR__ . '/Security/CSRFProtection.php';
use App\Security\CSRFProtection;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
// Validate CSRF token
CSRFProtection::requireToken();
// Process form data
$name = $_POST['name'] ?? '';
$email = $_POST['email'] ?? '';
// ... create user logic ...
header('Location: /users');
exit;
} catch (\RuntimeException $e) {
http_response_code(403);
die('CSRF validation failed');
}
}CSRF Protection for AJAX Requests
<?php
declare(strict_types=1);
// API endpoint to get CSRF token
if ($_SERVER['REQUEST_METHOD'] === 'GET' && $_SERVER['PATH_INFO'] === '/csrf-token') {
header('Content-Type: application/json');
echo json_encode([
'token' => CSRFProtection::generateToken()
]);
exit;
}
// AJAX request with CSRF token
?>
<script>
fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '<?= CSRFProtection::generateToken() ?>'
},
body: JSON.stringify({ name: 'John', email: 'john@example.com' })
});
</script>Section 5: Secure Password Hashing
Never store passwords in plain text. PHP provides strong password hashing functions that handle salting and secure algorithms automatically.
Password Hashing
<?php
declare(strict_types=1);
namespace App\Security;
class PasswordSecurity
{
/**
* Hash password using Argon2ID (recommended) or bcrypt
*/
public static function hashPassword(string $password): string
{
// PASSWORD_ARGON2ID is the strongest (PHP 7.2+)
// Falls back to PASSWORD_DEFAULT (bcrypt) if not available
$options = [
'memory_cost' => 65536, // 64 MB
'time_cost' => 4, // 4 iterations
'threads' => 3 // 3 threads
];
return password_hash($password, PASSWORD_ARGON2ID, $options);
}
/**
* Verify password against hash
*/
public static function verifyPassword(
string $password,
string $hash
): bool {
return password_verify($password, $hash);
}
/**
* Check if hash needs rehashing (algorithm upgraded)
*/
public static function needsRehash(string $hash): bool
{
return password_needs_rehash($hash, PASSWORD_ARGON2ID);
}
}Authentication with Rate Limiting
<?php
declare(strict_types=1);
namespace App\Security;
class Authentication
{
public function __construct(
private \PDO $pdo
) {}
/**
* Login with rate limiting
*/
public function login(string $email, string $password): bool
{
// Check rate limit
if ($this->isRateLimited($email)) {
sleep(2); // Slow down brute force attempts
throw new \RuntimeException('Too many login attempts. Please try again later.');
}
// Find user
$stmt = $this->pdo->prepare(
'SELECT id, email, password_hash FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$user) {
$this->recordFailedAttempt($email);
return false;
}
// Verify password
if (!password_verify($password, $user['password_hash'])) {
$this->recordFailedAttempt($email);
return false;
}
// Check if hash needs updating
if (password_needs_rehash($user['password_hash'], PASSWORD_ARGON2ID)) {
$newHash = password_hash($password, PASSWORD_ARGON2ID);
$updateStmt = $this->pdo->prepare(
'UPDATE users SET password_hash = :hash WHERE id = :id'
);
$updateStmt->execute([
'hash' => $newHash,
'id' => $user['id']
]);
}
// Clear failed attempts
$this->clearFailedAttempts($email);
// Start session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
session_regenerate_id(true); // Prevent session fixation
$_SESSION['user_id'] = $user['id'];
$_SESSION['email'] = $user['email'];
return true;
}
/**
* Check if email is rate limited
*/
private function isRateLimited(string $email): bool
{
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) as attempts, MAX(attempted_at) as last_attempt
FROM login_attempts
WHERE email = :email
AND attempted_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE)'
);
$stmt->execute(['email' => $email]);
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
return ($result['attempts'] ?? 0) >= 5;
}
/**
* Record failed login attempt
*/
private function recordFailedAttempt(string $email): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO login_attempts (email, attempted_at)
VALUES (:email, NOW())'
);
$stmt->execute(['email' => $email]);
}
/**
* Clear failed attempts after successful login
*/
private function clearFailedAttempts(string $email): void
{
$stmt = $this->pdo->prepare(
'DELETE FROM login_attempts WHERE email = :email'
);
$stmt->execute(['email' => $email]);
}
}Section 6: Security Headers
Security headers provide an additional layer of protection against various attack vectors. Set these headers for all responses.
Comprehensive Security Headers
<?php
declare(strict_types=1);
namespace App\Security;
class SecurityHeaders
{
/**
* Set all security headers
*/
public static function setAll(): void
{
self::setFrameOptions();
self::setContentTypeOptions();
self::setXSSProtection();
self::setReferrerPolicy();
self::setPermissionsPolicy();
self::setStrictTransportSecurity();
}
/**
* X-Frame-Options: Prevent clickjacking
*/
public static function setFrameOptions(string $value = 'DENY'): void
{
header("X-Frame-Options: {$value}");
}
/**
* X-Content-Type-Options: Prevent MIME sniffing
*/
public static function setContentTypeOptions(): void
{
header('X-Content-Type-Options: nosniff');
}
/**
* X-XSS-Protection: Enable browser XSS filter
*/
public static function setXSSProtection(): void
{
header('X-XSS-Protection: 1; mode=block');
}
/**
* Referrer-Policy: Control referrer information
*/
public static function setReferrerPolicy(string $policy = 'strict-origin-when-cross-origin'): void
{
header("Referrer-Policy: {$policy}");
}
/**
* Permissions-Policy: Control browser features
*/
public static function setPermissionsPolicy(): void
{
$policy = implode(', ', [
'geolocation=()',
'microphone=()',
'camera=()',
'payment=()'
]);
header("Permissions-Policy: {$policy}");
}
/**
* Strict-Transport-Security: Force HTTPS
*/
public static function setStrictTransportSecurity(int $maxAge = 31536000): void
{
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
header("Strict-Transport-Security: max-age={$maxAge}; includeSubDomains; preload");
}
}
/**
* Content-Security-Policy: Prevent XSS and injection attacks
*/
public static function setContentSecurityPolicy(string $policy): void
{
header("Content-Security-Policy: {$policy}");
}
}
// Usage: Set headers early in application
SecurityHeaders::setAll();Section 7: Secure File Uploads
File uploads are a common attack vector. Validate file types, scan for malware, and store files securely outside the web root.
Secure File Upload Handler
<?php
declare(strict_types=1);
namespace App\Security;
class SecureFileUpload
{
private const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
private const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp'
];
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
/**
* Validate and upload file securely
*/
public function upload(array $file, string $uploadDir): string
{
// Check for upload errors
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new \RuntimeException('File upload error: ' . $file['error']);
}
// Validate file size
if ($file['size'] > self::MAX_FILE_SIZE) {
throw new \RuntimeException('File too large');
}
// Validate MIME type by content (not extension)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new \RuntimeException('Invalid file type');
}
// Validate extension matches MIME type
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
throw new \RuntimeException('Invalid file extension');
}
// Generate secure filename
$filename = bin2hex(random_bytes(16)) . '.' . $extension;
$destination = $uploadDir . '/' . $filename;
// Ensure upload directory exists and is writable
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// Move uploaded file
if (!move_uploaded_file($file['tmp_name'], $destination)) {
throw new \RuntimeException('Failed to move uploaded file');
}
// Set proper permissions
chmod($destination, 0644);
return $filename;
}
/**
* Validate image dimensions
*/
public function validateImageDimensions(
string $filePath,
int $maxWidth = 2000,
int $maxHeight = 2000
): bool {
$imageInfo = getimagesize($filePath);
if ($imageInfo === false) {
return false;
}
[$width, $height] = $imageInfo;
return $width <= $maxWidth && $height <= $maxHeight;
}
}File Upload Usage
<?php
declare(strict_types=1);
require_once __DIR__ . '/Security/SecureFileUpload.php';
use App\Security\SecureFileUpload;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['avatar'])) {
try {
$uploader = new SecureFileUpload();
// Store outside web root
$uploadDir = '/var/www/uploads/avatars';
$filename = $uploader->upload($_FILES['avatar'], $uploadDir);
// Validate dimensions
if (!$uploader->validateImageDimensions($uploadDir . '/' . $filename)) {
unlink($uploadDir . '/' . $filename);
throw new \RuntimeException('Image dimensions too large');
}
// Save filename to database
// $user->setAvatar($filename);
echo "File uploaded successfully: {$filename}";
} catch (\RuntimeException $e) {
echo "Upload failed: " . $e->getMessage();
}
}Section 8: Input Validation and Sanitization
Always validate input on the server side, even if you validate on the client. Sanitize data before storing or displaying it.
Input Validation
<?php
declare(strict_types=1);
namespace App\Security;
class InputValidator
{
/**
* Validate email
*/
public static function validateEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
/**
* Validate URL
*/
public static function validateUrl(string $url): bool
{
return filter_var($url, FILTER_VALIDATE_URL) !== false;
}
/**
* Validate integer range
*/
public static function validateInt(
mixed $value,
int $min = PHP_INT_MIN,
int $max = PHP_INT_MAX
): bool {
$options = [
'options' => [
'min_range' => $min,
'max_range' => $max
]
];
return filter_var($value, FILTER_VALIDATE_INT, $options) !== false;
}
/**
* Validate string length
*/
public static function validateStringLength(
string $string,
int $min = 0,
int $max = PHP_INT_MAX
): bool {
$length = mb_strlen($string, 'UTF-8');
return $length >= $min && $length <= $max;
}
/**
* Validate against regex pattern
*/
public static function validatePattern(string $value, string $pattern): bool
{
return preg_match($pattern, $value) === 1;
}
}Input Sanitization
<?php
declare(strict_types=1);
namespace App\Security;
class InputSanitizer
{
/**
* Sanitize string (remove tags, encode special chars)
*/
public static function sanitizeString(string $input): string
{
// Remove HTML tags
$cleaned = strip_tags($input);
// Trim whitespace
$cleaned = trim($cleaned);
// Remove null bytes
$cleaned = str_replace("\0", '', $cleaned);
return $cleaned;
}
/**
* Sanitize email
*/
public static function sanitizeEmail(string $email): string
{
return filter_var($email, FILTER_SANITIZE_EMAIL);
}
/**
* Sanitize URL
*/
public static function sanitizeUrl(string $url): string
{
return filter_var($url, FILTER_SANITIZE_URL);
}
/**
* Sanitize integer
*/
public static function sanitizeInt(mixed $value): ?int
{
return filter_var($value, FILTER_SANITIZE_NUMBER_INT);
}
/**
* Sanitize for SQL (use prepared statements instead!)
*/
public static function sanitizeForSql(string $input): string
{
// This is a fallback - ALWAYS use prepared statements
return addslashes($input);
}
}Section 9: Secure Session Management
Sessions must be configured securely to prevent session hijacking and fixation attacks.
Secure Session Configuration
<?php
declare(strict_types=1);
namespace App\Security;
class SecureSession
{
/**
* Start secure session
*/
public static function start(): void
{
// Configure session before starting
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_secure', '1'); // HTTPS only
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.use_strict_mode', '1');
ini_set('session.cookie_lifetime', '0'); // Until browser closes
ini_set('session.gc_maxlifetime', '1800'); // 30 minutes
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
/**
* Regenerate session ID (prevent session fixation)
*/
public static function regenerate(): void
{
session_regenerate_id(true);
}
/**
* Destroy session securely
*/
public static function destroy(): void
{
$_SESSION = [];
if (isset($_COOKIE[session_name()])) {
setcookie(
session_name(),
'',
time() - 3600,
'/',
'',
true, // secure
true // httponly
);
}
session_destroy();
}
/**
* Check if session is valid
*/
public static function isValid(): bool
{
if (session_status() === PHP_SESSION_NONE) {
return false;
}
// Check session timeout
if (isset($_SESSION['last_activity']) &&
(time() - $_SESSION['last_activity']) > 1800) {
return false;
}
$_SESSION['last_activity'] = time();
return true;
}
}Section 10: Error Handling Without Information Disclosure
Never expose sensitive information in error messages. Log errors securely, but show generic messages to users.
Secure Error Handling
<?php
declare(strict_types=1);
namespace App\Security;
class SecureErrorHandler
{
/**
* Handle errors securely
*/
public static function handleError(
int $errno,
string $errstr,
string $errfile,
int $errline
): bool {
// Log full error details
error_log(sprintf(
"Error [%d]: %s in %s on line %d",
$errno,
$errstr,
$errfile,
$errline
));
// Show generic message to user
if (error_reporting() !== 0) {
http_response_code(500);
echo json_encode([
'error' => 'An internal error occurred. Please try again later.'
]);
}
return true;
}
/**
* Handle exceptions securely
*/
public static function handleException(\Throwable $exception): void
{
// Log full exception details
error_log(sprintf(
"Exception: %s in %s:%d\nStack trace:\n%s",
$exception->getMessage(),
$exception->getFile(),
$exception->getLine(),
$exception->getTraceAsString()
));
// Show generic message
http_response_code(500);
if (php_sapi_name() === 'cli') {
// Show full error in CLI
echo $exception->getMessage() . "\n";
echo $exception->getTraceAsString() . "\n";
} else {
// Generic message for web
echo json_encode([
'error' => 'An error occurred processing your request.'
]);
}
}
}
// Set error handlers
set_error_handler([SecureErrorHandler::class, 'handleError']);
set_exception_handler([SecureErrorHandler::class, 'handleException']);Section 11: Authorization and Access Control
Authorization determines what authenticated users are allowed to do. Unlike authentication (who you are), authorization answers "what can you do?" This is critical for preventing unauthorized access to resources.
Role-Based Access Control (RBAC)
<?php
declare(strict_types=1);
namespace App\Security;
class Authorization
{
/**
* Check if user has specific role
*/
public static function hasRole(string $role): bool
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$userRoles = $_SESSION['roles'] ?? [];
return in_array($role, $userRoles, true);
}
/**
* Check if user has any of the specified roles
*/
public static function hasAnyRole(array $roles): bool
{
foreach ($roles as $role) {
if (self::hasRole($role)) {
return true;
}
}
return false;
}
/**
* Check if user has all specified roles
*/
public static function hasAllRoles(array $roles): bool
{
foreach ($roles as $role) {
if (!self::hasRole($role)) {
return false;
}
}
return true;
}
/**
* Require role or throw exception
*/
public static function requireRole(string $role): void
{
if (!self::hasRole($role)) {
http_response_code(403);
throw new \RuntimeException("Access denied. Required role: {$role}");
}
}
}Permission-Based Access Control
<?php
declare(strict_types=1);
namespace App\Security;
class PermissionManager
{
/**
* Check if user has permission for specific action
*/
public static function can(string $action, ?string $resource = null): bool
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$permissions = $_SESSION['permissions'] ?? [];
// Check specific resource permission
if ($resource !== null) {
$key = "{$action}:{$resource}";
if (isset($permissions[$key])) {
return $permissions[$key] === true;
}
}
// Check general permission
return $permissions[$action] ?? false;
}
/**
* Require permission or throw exception
*/
public static function requirePermission(string $action, ?string $resource = null): void
{
if (!self::can($action, $resource)) {
http_response_code(403);
throw new \RuntimeException("Permission denied: {$action}");
}
}
}
// Usage
PermissionManager::requirePermission('edit', 'post');
PermissionManager::requirePermission('delete', 'user');Resource Ownership Validation
<?php
declare(strict_types=1);
namespace App\Security;
class ResourceAuthorization
{
public function __construct(
private \PDO $pdo
) {}
/**
* Check if user owns a resource
*/
public function ownsPost(int $postId, int $userId): bool
{
$stmt = $this->pdo->prepare(
'SELECT user_id FROM posts WHERE id = :id'
);
$stmt->execute(['id' => $postId]);
$post = $stmt->fetch(\PDO::FETCH_ASSOC);
return $post && (int)$post['user_id'] === $userId;
}
/**
* Check if user can access resource (owner or admin)
*/
public function canAccessPost(int $postId, int $userId, array $userRoles): bool
{
// Admins can access any post
if (in_array('admin', $userRoles, true)) {
return true;
}
// Check ownership
return $this->ownsPost($postId, $userId);
}
/**
* Require resource access or throw exception
*/
public function requirePostAccess(int $postId, int $userId, array $userRoles): void
{
if (!$this->canAccessPost($postId, $userId, $userRoles)) {
http_response_code(403);
throw new \RuntimeException('Access denied to this resource');
}
}
}Middleware for Authorization
<?php
declare(strict_types=1);
namespace App\Security;
class AuthorizationMiddleware
{
public function __construct(
private string $requiredRole
) {}
public function handle(\Closure $next): mixed
{
if (!Authorization::hasRole($this->requiredRole)) {
http_response_code(403);
return ['error' => 'Access denied'];
}
return $next();
}
}
// Usage in router
$router->get('/admin/users', function() {
return ['users' => []];
})->middleware(new AuthorizationMiddleware('admin'));Section 12: Command Injection Prevention
Command injection occurs when user input is passed to system commands without proper sanitization. This allows attackers to execute arbitrary commands on the server.
The Problem: Unsafe Command Execution
<?php
declare(strict_types=1);
// ❌ DANGEROUS: Direct user input in command
$filename = $_GET['file'] ?? '';
exec("cat /var/logs/{$filename}"); // Attacker can inject: file.txt; rm -rf /
// ❌ DANGEROUS: Shell command with user input
$email = $_POST['email'] ?? '';
system("mail -s 'Welcome' {$email}"); // Attacker can inject: user@example.com; rm -rf /The Solution: Safe Command Execution
<?php
declare(strict_types=1);
namespace App\Security;
class SafeCommandExecution
{
/**
* ✅ SAFE: Use escapeshellarg() for single argument
*/
public static function executeSafeCommand(string $command, string $argument): string
{
$escapedArg = escapeshellarg($argument);
$fullCommand = "{$command} {$escapedArg}";
return shell_exec($fullCommand);
}
/**
* ✅ SAFE: Use escapeshellcmd() for entire command
*/
public static function executeSafeShellCommand(string $command): string
{
$escapedCmd = escapeshellcmd($command);
return shell_exec($escapedCmd);
}
/**
* ✅ SAFE: Use proc_open() with array arguments
*/
public static function executeWithArrayArgs(
string $command,
array $arguments
): string {
$escapedArgs = array_map('escapeshellarg', $arguments);
$fullCommand = $command . ' ' . implode(' ', $escapedArgs);
return shell_exec($fullCommand);
}
/**
* ✅ SAFE: Validate and whitelist allowed commands
*/
public static function executeWhitelistedCommand(
string $command,
string $argument
): string {
$allowedCommands = ['ls', 'cat', 'grep'];
if (!in_array($command, $allowedCommands, true)) {
throw new \InvalidArgumentException("Command not allowed: {$command}");
}
// Validate argument format
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $argument)) {
throw new \InvalidArgumentException("Invalid argument format");
}
$escapedArg = escapeshellarg($argument);
return shell_exec("{$command} {$escapedArg}");
}
}Safe File Operations
<?php
declare(strict_types=1);
namespace App\Security;
class SafeFileOperations
{
/**
* ✅ SAFE: Read file without shell commands
*/
public static function readFile(string $filename): string
{
// Validate filename (prevent directory traversal)
$filename = basename($filename);
// Whitelist allowed characters
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $filename)) {
throw new \InvalidArgumentException("Invalid filename");
}
$path = '/var/logs/' . $filename;
// Check if file exists and is readable
if (!file_exists($path) || !is_readable($path)) {
throw new \RuntimeException("File not accessible");
}
return file_get_contents($path);
}
/**
* ✅ SAFE: Use PHP functions instead of shell commands
*/
public static function listDirectory(string $directory): array
{
// Validate directory path
$directory = realpath($directory);
if ($directory === false || !is_dir($directory)) {
throw new \InvalidArgumentException("Invalid directory");
}
// Use PHP functions instead of exec('ls')
return array_filter(scandir($directory), function($file) {
return $file !== '.' && $file !== '..';
});
}
}Why It Works
escapeshellarg(): Wraps argument in single quotes and escapes any single quotesescapeshellcmd(): Escapes shell metacharacters in entire command- Whitelisting: Only allow specific, safe commands
- Input validation: Validate format before using in commands
- PHP alternatives: Use PHP functions instead of shell commands when possible
Section 13: XXE (XML External Entity) Prevention
XXE attacks exploit XML parsers that process external entity references, potentially exposing files or causing denial of service.
The Problem: Unsafe XML Parsing
<?php
declare(strict_types=1);
// ❌ DANGEROUS: Allows external entities
$xml = <<<XML
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<foo>&xxe;</foo>
XML;
$data = simplexml_load_string($xml); // Reads /etc/passwd!The Solution: Disable External Entities
<?php
declare(strict_types=1);
namespace App\Security;
class SafeXMLParser
{
/**
* ✅ SAFE: Parse XML with external entities disabled
*/
public static function parseXML(string $xmlString): \SimpleXMLElement
{
// Disable external entity loading
$previousValue = libxml_disable_entity_loader(true);
// Disable external entity processing
libxml_set_external_entity_loader(function() {
return null; // Block all external entities
});
try {
$xml = simplexml_load_string($xmlString, 'SimpleXMLElement', LIBXML_NOENT);
if ($xml === false) {
throw new \RuntimeException('Failed to parse XML');
}
return $xml;
} finally {
// Restore previous setting
libxml_disable_entity_loader($previousValue);
}
}
/**
* ✅ SAFE: Use DOMDocument with secure settings
*/
public static function parseXMLSecure(string $xmlString): \DOMDocument
{
$dom = new \DOMDocument();
// Disable external entity loading
$previousValue = libxml_disable_entity_loader(true);
try {
// Load XML with secure flags
$dom->loadXML($xmlString, LIBXML_NOENT | LIBXML_DTDLOAD);
return $dom;
} finally {
libxml_disable_entity_loader($previousValue);
}
}
/**
* ✅ SAFE: Validate XML structure before parsing
*/
public static function validateAndParse(string $xmlString): \SimpleXMLElement
{
// Check for dangerous patterns
if (preg_match('/<!ENTITY\s+\w+\s+SYSTEM/i', $xmlString)) {
throw new \RuntimeException('External entities not allowed');
}
if (preg_match('/<!DOCTYPE/i', $xmlString)) {
throw new \RuntimeException('DOCTYPE declarations not allowed');
}
return self::parseXML($xmlString);
}
}Configuration: php.ini Settings
; Disable external entity loading globally
libxml_disable_entity_loader = trueSection 14: Deserialization Security
Unsafe deserialization can lead to object injection attacks, allowing attackers to execute arbitrary code or access sensitive data.
The Problem: Unsafe Deserialization
<?php
declare(strict_types=1);
// ❌ DANGEROUS: Deserializing untrusted data
$data = $_COOKIE['user_data'] ?? '';
$user = unserialize($data); // Attacker can inject malicious objects!
// ❌ DANGEROUS: Magic methods can execute code
class User {
public function __wakeup() {
// This executes during unserialize!
system($_GET['cmd']);
}
}The Solution: Safe Serialization Alternatives
<?php
declare(strict_types=1);
namespace App\Security;
class SafeSerialization
{
/**
* ✅ SAFE: Use JSON instead of serialize()
*/
public static function serializeToJson(array $data): string
{
return json_encode($data, JSON_THROW_ON_ERROR);
}
public static function deserializeFromJson(string $json): array
{
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
}
/**
* ✅ SAFE: Whitelist allowed classes for deserialization
*/
public static function safeUnserialize(
string $data,
array $allowedClasses = []
): mixed {
// Use allowed_classes option (PHP 7.0+)
$options = ['allowed_classes' => $allowedClasses];
return unserialize($data, $options);
}
/**
* ✅ SAFE: Validate serialized data before deserializing
*/
public static function validateAndDeserialize(
string $data,
array $allowedClasses = []
): mixed {
// Check for dangerous patterns
if (preg_match('/[OoC]:\d+:"/', $data)) {
// Contains object or class reference
if (empty($allowedClasses)) {
throw new \RuntimeException('Object deserialization not allowed');
}
}
return self::safeUnserialize($data, $allowedClasses);
}
}
// Usage
$data = SafeSerialization::serializeToJson(['name' => 'John', 'email' => 'john@example.com']);
$user = SafeSerialization::deserializeFromJson($data);
// If you must use serialize(), whitelist classes
$user = SafeSerialization::safeUnserialize($serialized, [User::class]);Best Practices
<?php
declare(strict_types=1);
// ✅ Prefer JSON for data serialization
$data = json_encode($userData);
$userData = json_decode($data, true);
// ✅ If using serialize(), whitelist classes
$user = unserialize($data, ['allowed_classes' => [User::class]]);
// ✅ Never deserialize untrusted input
// Always validate source and signatureSection 15: Mass Assignment Prevention
Mass assignment vulnerabilities occur when applications allow users to update fields they shouldn't have access to by including them in form data.
The Problem: Mass Assignment Vulnerability
<?php
declare(strict_types=1);
// ❌ DANGEROUS: Updating all fields from request
$userData = $_POST;
$stmt = $pdo->prepare('UPDATE users SET name = :name, email = :email, role = :role WHERE id = :id');
$stmt->execute($userData); // Attacker can set role = 'admin'!The Solution: Whitelist Allowed Fields
<?php
declare(strict_types=1);
namespace App\Security;
class MassAssignmentProtection
{
/**
* ✅ SAFE: Whitelist allowed fields
*/
public static function filterFields(
array $data,
array $allowedFields
): array {
return array_intersect_key($data, array_flip($allowedFields));
}
/**
* ✅ SAFE: Update only specific fields
*/
public static function updateUser(
\PDO $pdo,
int $userId,
array $data
): void {
// Whitelist allowed fields
$allowedFields = ['name', 'email', 'bio'];
$filteredData = self::filterFields($data, $allowedFields);
if (empty($filteredData)) {
return;
}
// Build dynamic UPDATE query
$fields = [];
$params = ['id' => $userId];
foreach ($filteredData as $field => $value) {
$fields[] = "{$field} = :{$field}";
$params[$field] = $value;
}
$sql = 'UPDATE users SET ' . implode(', ', $fields) . ' WHERE id = :id';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
}
/**
* ✅ SAFE: Role-based field filtering
*/
public static function filterFieldsByRole(
array $data,
string $userRole
): array {
$roleFields = [
'user' => ['name', 'email', 'bio'],
'admin' => ['name', 'email', 'bio', 'role', 'status'],
];
$allowedFields = $roleFields[$userRole] ?? $roleFields['user'];
return self::filterFields($data, $allowedFields);
}
}Using in Controllers
<?php
declare(strict_types=1);
// In controller
$allowedFields = ['name', 'email', 'bio'];
$userData = MassAssignmentProtection::filterFields($_POST, $allowedFields);
$stmt = $pdo->prepare('UPDATE users SET name = :name, email = :email, bio = :bio WHERE id = :id');
$stmt->execute(array_merge($userData, ['id' => $userId]));Section 16: IDOR (Insecure Direct Object References) Prevention
IDOR vulnerabilities occur when applications expose internal implementation details (like database IDs) and fail to verify user access to those resources.
The Problem: Direct Object Reference
<?php
declare(strict_types=1);
// ❌ DANGEROUS: No access control check
$postId = $_GET['id'] ?? 0;
$stmt = $pdo->prepare('SELECT * FROM posts WHERE id = :id');
$stmt->execute(['id' => $postId]);
$post = $stmt->fetch(); // User can access any post by changing ID!The Solution: Resource Access Validation
<?php
declare(strict_types=1);
namespace App\Security;
class IDORProtection
{
public function __construct(
private \PDO $pdo
) {}
/**
* ✅ SAFE: Verify user owns resource
*/
public function getPostForUser(int $postId, int $userId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM posts WHERE id = :id AND user_id = :user_id'
);
$stmt->execute([
'id' => $postId,
'user_id' => $userId
]);
return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
}
/**
* ✅ SAFE: Check access before returning resource
*/
public function requirePostAccess(int $postId, int $userId, array $userRoles): array
{
// Admins can access any post
if (in_array('admin', $userRoles, true)) {
$stmt = $this->pdo->prepare('SELECT * FROM posts WHERE id = :id');
$stmt->execute(['id' => $postId]);
} else {
// Regular users can only access their own posts
$stmt = $this->pdo->prepare(
'SELECT * FROM posts WHERE id = :id AND user_id = :user_id'
);
$stmt->execute([
'id' => $postId,
'user_id' => $userId
]);
}
$post = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$post) {
http_response_code(404); // Don't reveal resource exists
throw new \RuntimeException('Post not found');
}
return $post;
}
/**
* ✅ SAFE: Use indirect references (tokens instead of IDs)
*/
public function getPostByToken(string $token): ?array
{
$stmt = $this->pdo->prepare(
'SELECT p.* FROM posts p
JOIN post_tokens pt ON p.id = pt.post_id
WHERE pt.token = :token AND pt.expires_at > NOW()'
);
$stmt->execute(['token' => $token]);
return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
}
}Using Indirect References
<?php
declare(strict_types=1);
// Generate shareable token instead of exposing ID
$token = bin2hex(random_bytes(32));
$stmt = $pdo->prepare(
'INSERT INTO post_tokens (post_id, token, expires_at)
VALUES (:post_id, :token, DATE_ADD(NOW(), INTERVAL 7 DAY))'
);
$stmt->execute([
'post_id' => $postId,
'token' => $token
]);
// Share URL: /post?token=abc123... instead of /post?id=42Section 17: Secure Configuration Management
Sensitive configuration data like API keys, database passwords, and secrets must be stored securely and never committed to version control.
Environment Variables
<?php
declare(strict_types=1);
namespace App\Config;
class Environment
{
/**
* Get environment variable with default
*/
public static function get(string $key, ?string $default = null): ?string
{
$value = $_ENV[$key] ?? getenv($key);
return $value !== false ? $value : $default;
}
/**
* Require environment variable (throws if missing)
*/
public static function require(string $key): string
{
$value = self::get($key);
if ($value === null) {
throw new \RuntimeException("Required environment variable not set: {$key}");
}
return $value;
}
/**
* Get boolean environment variable
*/
public static function getBool(string $key, bool $default = false): bool
{
$value = self::get($key);
if ($value === null) {
return $default;
}
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
/**
* Get integer environment variable
*/
public static function getInt(string $key, int $default = 0): int
{
$value = self::get($key);
if ($value === null) {
return $default;
}
return (int) $value;
}
}Loading .env Files Securely
<?php
declare(strict_types=1);
namespace App\Config;
class EnvLoader
{
/**
* Load .env file securely
*/
public static function load(string $envFile = '.env'): void
{
if (!file_exists($envFile)) {
throw new \RuntimeException("Environment file not found: {$envFile}");
}
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
// Skip comments
if (strpos(trim($line), '#') === 0) {
continue;
}
// Parse KEY=VALUE
if (strpos($line, '=') === false) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Remove quotes if present
$value = trim($value, '"\'');
// Set environment variable if not already set
if (!isset($_ENV[$key]) && !getenv($key)) {
$_ENV[$key] = $value;
putenv("{$key}={$value}");
}
}
}
}
// Usage: Load at application startup
EnvLoader::load();
// Access securely
$dbPassword = Environment::require('DB_PASSWORD');
$apiKey = Environment::require('API_KEY');.env File Example
# .env.example (commit this)
DB_HOST=localhost
DB_NAME=myapp
DB_USER=myuser
DB_PASSWORD=
# .env (DO NOT COMMIT - add to .gitignore)
DB_HOST=localhost
DB_NAME=myapp_production
DB_USER=prod_user
DB_PASSWORD=super_secret_password_123
API_KEY=sk_live_abc123xyz789.gitignore Configuration
# Environment files
.env
.env.local
.env.*.local
# Secrets
secrets/
*.key
*.pemSecure Secret Storage
<?php
declare(strict_types=1);
namespace App\Security;
class SecretManager
{
/**
* Encrypt sensitive value before storage
*/
public static function encrypt(string $value, string $key): string
{
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$encrypted = sodium_crypto_secretbox($value, $nonce, $key);
return base64_encode($nonce . $encrypted);
}
/**
* Decrypt stored value
*/
public static function decrypt(string $encrypted, string $key): string
{
$data = base64_decode($encrypted, true);
if ($data === false) {
throw new \RuntimeException('Invalid encrypted data');
}
$nonce = mb_substr($data, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit');
$ciphertext = mb_substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit');
$decrypted = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);
if ($decrypted === false) {
throw new \RuntimeException('Decryption failed');
}
return $decrypted;
}
}Section 18: Dependency Vulnerability Scanning
Keeping dependencies updated and scanning for known vulnerabilities is critical for application security.
Using Composer Audit
# Check for known vulnerabilities
composer audit
# Update dependencies
composer update
# Update specific package
composer update vendor/package-name
# Check outdated packages
composer outdatedAutomated Security Checks
<?php
declare(strict_types=1);
namespace App\Security;
class DependencyScanner
{
/**
* Run composer audit and parse results
*/
public static function audit(): array
{
$output = [];
$returnCode = 0;
exec('composer audit --format=json 2>&1', $output, $returnCode);
$json = implode("\n", $output);
$data = json_decode($json, true);
return [
'vulnerabilities' => $data['advisories'] ?? [],
'count' => count($data['advisories'] ?? []),
'has_vulnerabilities' => $returnCode !== 0
];
}
/**
* Check if specific package has vulnerabilities
*/
public static function hasVulnerabilities(string $package): bool
{
$audit = self::audit();
foreach ($audit['vulnerabilities'] as $vuln) {
if ($vuln['package'] === $package) {
return true;
}
}
return false;
}
}CI/CD Integration
# .github/workflows/security.yml
name: Security Audit
on:
schedule:
- cron: '0 0 * * 0' # Weekly
pull_request:
paths:
- 'composer.json'
- 'composer.lock'
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: php-actions/composer@v6
- name: Run security audit
run: composer auditBest Practices
- ✅ Run
composer auditregularly (weekly/monthly) - ✅ Update dependencies promptly when vulnerabilities are found
- ✅ Pin dependency versions in
composer.lock - ✅ Review changelogs before updating major versions
- ✅ Use
composer update --with-dependenciesto update transitive dependencies
Section 19: Encryption at Rest
Sensitive data stored in databases should be encrypted to protect against data breaches.
Database Field Encryption
<?php
declare(strict_types=1);
namespace App\Security;
class Encryption
{
private string $key;
public function __construct(string $encryptionKey)
{
if (strlen($encryptionKey) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
throw new \InvalidArgumentException('Invalid key length');
}
$this->key = $encryptionKey;
}
/**
* Encrypt sensitive data before storage
*/
public function encrypt(string $plaintext): string
{
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $this->key);
// Store nonce with ciphertext
return base64_encode($nonce . $ciphertext);
}
/**
* Decrypt stored data
*/
public function decrypt(string $encrypted): string
{
$data = base64_decode($encrypted, true);
if ($data === false) {
throw new \RuntimeException('Invalid encrypted data');
}
$nonce = mb_substr($data, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit');
$ciphertext = mb_substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit');
$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key);
if ($plaintext === false) {
throw new \RuntimeException('Decryption failed');
}
return $plaintext;
}
}Using Encryption in Models
<?php
declare(strict_types=1);
namespace App\Models;
use App\Security\Encryption;
class User
{
public function __construct(
private \PDO $pdo,
private Encryption $encryption
) {}
/**
* Store encrypted credit card number
*/
public function updateCreditCard(int $userId, string $cardNumber): void
{
$encrypted = $this->encryption->encrypt($cardNumber);
$stmt = $this->pdo->prepare(
'UPDATE users SET credit_card_encrypted = :encrypted WHERE id = :id'
);
$stmt->execute([
'encrypted' => $encrypted,
'id' => $userId
]);
}
/**
* Retrieve and decrypt credit card
*/
public function getCreditCard(int $userId): ?string
{
$stmt = $this->pdo->prepare(
'SELECT credit_card_encrypted FROM users WHERE id = :id'
);
$stmt->execute(['id' => $userId]);
$user = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$user || !$user['credit_card_encrypted']) {
return null;
}
return $this->encryption->decrypt($user['credit_card_encrypted']);
}
}Key Management
<?php
declare(strict_types=1);
namespace App\Security;
class KeyManager
{
/**
* Generate encryption key
*/
public static function generateKey(): string
{
return sodium_crypto_secretbox_keygen();
}
/**
* Load key from secure storage
*/
public static function loadKey(): string
{
$keyPath = Environment::require('ENCRYPTION_KEY_PATH');
if (!file_exists($keyPath)) {
throw new \RuntimeException("Encryption key not found at: {$keyPath}");
}
$key = file_get_contents($keyPath);
if (strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
throw new \RuntimeException("Invalid key length");
}
return $key;
}
}Section 20: Security Logging and Monitoring
Comprehensive security logging helps detect attacks and investigate security incidents.
Security Event Logging
<?php
declare(strict_types=1);
namespace App\Security;
class SecurityLogger
{
private string $logFile;
public function __construct(string $logFile = '/var/log/security.log')
{
$this->logFile = $logFile;
}
/**
* Log security event
*/
public function logEvent(
string $eventType,
array $context = [],
string $severity = 'INFO'
): void {
$logEntry = [
'timestamp' => date('Y-m-d H:i:s'),
'severity' => $severity,
'event' => $eventType,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
'context' => $context
];
$message = json_encode($logEntry, JSON_UNESCAPED_SLASHES) . "\n";
error_log($message, 3, $this->logFile);
}
/**
* Log failed login attempt
*/
public function logFailedLogin(string $email, string $reason = ''): void
{
$this->logEvent('FAILED_LOGIN', [
'email' => $email,
'reason' => $reason
], 'WARNING');
}
/**
* Log successful login
*/
public function logSuccessfulLogin(int $userId, string $email): void
{
$this->logEvent('SUCCESSFUL_LOGIN', [
'user_id' => $userId,
'email' => $email
], 'INFO');
}
/**
* Log unauthorized access attempt
*/
public function logUnauthorizedAccess(string $resource, string $action): void
{
$this->logEvent('UNAUTHORIZED_ACCESS', [
'resource' => $resource,
'action' => $action
], 'ALERT');
}
/**
* Log suspicious activity
*/
public function logSuspiciousActivity(string $description, array $details = []): void
{
$this->logEvent('SUSPICIOUS_ACTIVITY', [
'description' => $description,
'details' => $details
], 'CRITICAL');
}
}Intrusion Detection Patterns
<?php
declare(strict_types=1);
namespace App\Security;
class IntrusionDetection
{
public function __construct(
private \PDO $pdo,
private SecurityLogger $logger
) {}
/**
* Detect brute force attempts
*/
public function detectBruteForce(string $identifier, int $threshold = 5): bool
{
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) as attempts
FROM security_logs
WHERE event = "FAILED_LOGIN"
AND identifier = :identifier
AND created_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE)'
);
$stmt->execute(['identifier' => $identifier]);
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
$attempts = (int) ($result['attempts'] ?? 0);
if ($attempts >= $threshold) {
$this->logger->logSuspiciousActivity('Brute force detected', [
'identifier' => $identifier,
'attempts' => $attempts
]);
return true;
}
return false;
}
/**
* Detect SQL injection attempts
*/
public function detectSQLInjection(string $input): bool
{
$patterns = [
'/(\bUNION\b.*\bSELECT\b)/i',
'/(\bOR\b.*=.*)/i',
'/(\bAND\b.*=.*)/i',
'/(\bEXEC\b|\bEXECUTE\b)/i',
'/(\bDROP\b.*\bTABLE\b)/i',
'/(\bINSERT\b.*\bINTO\b)/i',
'/(\bDELETE\b.*\bFROM\b)/i',
'/(\bUPDATE\b.*\bSET\b)/i',
'/(\'\s*OR\s*\'\d+\'=\'\d+)/i',
'/(;\s*DROP\s+TABLE)/i'
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $input)) {
$this->logger->logSuspiciousActivity('SQL injection attempt detected', [
'input' => substr($input, 0, 100) // Log first 100 chars
]);
return true;
}
}
return false;
}
/**
* Detect XSS attempts
*/
public function detectXSS(string $input): bool
{
$patterns = [
'/<script[^>]*>/i',
'/javascript:/i',
'/on\w+\s*=/i',
'/<iframe[^>]*>/i',
'/<object[^>]*>/i',
'/<embed[^>]*>/i'
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $input)) {
$this->logger->logSuspiciousActivity('XSS attempt detected', [
'input' => substr($input, 0, 100)
]);
return true;
}
}
return false;
}
}Log Analysis
<?php
declare(strict_types=1);
namespace App\Security;
class LogAnalyzer
{
public function __construct(
private string $logFile
) {}
/**
* Analyze security logs for patterns
*/
public function analyze(int $hours = 24): array
{
$lines = file($this->logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$cutoff = time() - ($hours * 3600);
$analysis = [
'failed_logins' => 0,
'unauthorized_access' => 0,
'suspicious_activities' => 0,
'top_ips' => [],
'top_events' => []
];
foreach ($lines as $line) {
$entry = json_decode($line, true);
if (!$entry || $entry['timestamp'] < date('Y-m-d H:i:s', $cutoff)) {
continue;
}
// Count events
switch ($entry['event']) {
case 'FAILED_LOGIN':
$analysis['failed_logins']++;
break;
case 'UNAUTHORIZED_ACCESS':
$analysis['unauthorized_access']++;
break;
case 'SUSPICIOUS_ACTIVITY':
$analysis['suspicious_activities']++;
break;
}
// Track IPs
$ip = $entry['ip'] ?? 'unknown';
$analysis['top_ips'][$ip] = ($analysis['top_ips'][$ip] ?? 0) + 1;
// Track events
$event = $entry['event'];
$analysis['top_events'][$event] = ($analysis['top_events'][$event] ?? 0) + 1;
}
// Sort top IPs and events
arsort($analysis['top_ips']);
arsort($analysis['top_events']);
$analysis['top_ips'] = array_slice($analysis['top_ips'], 0, 10, true);
$analysis['top_events'] = array_slice($analysis['top_events'], 0, 10, true);
return $analysis;
}
}Best Practices Summary
✅ Always use prepared statements - Never concatenate user input into SQL queries ✅ Escape all output - Use htmlspecialchars() for HTML, json_encode() for JavaScript ✅ Implement CSRF protection - Use tokens for all state-changing operations ✅ Hash passwords securely - Use password_hash() with PASSWORD_ARGON2ID ✅ Set security headers - Configure all recommended security headers ✅ Validate file uploads - Check MIME type by content, not extension ✅ Validate and sanitize input - Validate early, sanitize before storage ✅ Configure sessions securely - Use httponly, secure, and samesite cookies ✅ Handle errors securely - Log details, show generic messages to users ✅ Implement authorization checks - Verify user permissions for all resources ✅ Prevent command injection - Use escapeshellarg() and whitelist commands ✅ Disable XML external entities - Prevent XXE attacks in XML parsing ✅ Avoid unsafe deserialization - Prefer JSON, whitelist classes if using serialize() ✅ Prevent mass assignment - Whitelist allowed fields, filter by role ✅ Validate resource access - Check ownership and permissions (prevent IDOR) ✅ Secure configuration management - Use environment variables, never commit secrets ✅ Scan dependencies regularly - Use composer audit to find vulnerabilities ✅ Encrypt sensitive data at rest - Use sodium encryption for database fields ✅ Log security events - Monitor failed logins, unauthorized access, suspicious activity ✅ Keep dependencies updated - Regularly update Composer packages ✅ Use HTTPS in production - Encrypt all traffic ✅ Implement rate limiting - Prevent brute force attacks ✅ Follow principle of least privilege - Grant minimum necessary permissions ✅ Regular security audits - Review code for vulnerabilities
Exercises
Exercise 1: Secure Login System
Goal: Implement a secure login system with password hashing and rate limiting
Create a file called secure-login.php and implement:
- Password hashing using
PASSWORD_ARGON2ID - Rate limiting (max 5 attempts per 15 minutes)
- Session regeneration after login
- CSRF token validation
Validation: Test your implementation:
// Test password hashing
$hash = password_hash('test123', PASSWORD_ARGON2ID);
echo password_verify('test123', $hash) ? "Password verified\n" : "Password failed\n";
// Test rate limiting
// Attempt 6 logins within 15 minutes - should be blockedExercise 2: CSRF Protection Middleware
Goal: Create a reusable CSRF protection middleware
Create a file called csrf-middleware.php and implement:
- Token generation and storage
- Token validation with timing attack prevention
- Middleware that can be applied to routes
- Token refresh mechanism
Validation: Test CSRF protection:
// Generate token
$token = CSRFProtection::generateToken();
// Validate correct token
echo CSRFProtection::validateToken($token) ? "Valid\n" : "Invalid\n";
// Validate incorrect token
echo CSRFProtection::validateToken('wrong') ? "Valid\n" : "Invalid\n";Exercise 3: Secure File Upload Handler
Goal: Build a secure file upload system
Create a file called secure-upload.php and implement:
- MIME type validation by content (not extension)
- File size limits
- Secure filename generation
- Image dimension validation
- Storage outside web root
Validation: Test file upload security:
// Test valid image upload
// Test invalid file type
// Test oversized file
// Test malicious filenameTroubleshooting
Error: "SQLSTATE[HY000]: General error"
Symptom: Prepared statement fails with general error
Cause: Parameter count mismatch or invalid parameter types
Solution: Ensure parameter count matches placeholders:
// ❌ Wrong: Missing parameter
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email AND role = :role');
$stmt->execute(['email' => $email]); // Missing 'role'
// ✅ Correct: All parameters provided
$stmt->execute(['email' => $email, 'role' => $role]);Problem: XSS Still Occurring Despite Escaping
Symptom: Scripts execute even after using htmlspecialchars()
Cause: Escaping in wrong context (e.g., HTML escaping in JavaScript)
Solution: Use context-appropriate escaping:
// ❌ Wrong: HTML escaping in JavaScript
echo '<script>var name = "' . htmlspecialchars($name) . '";</script>';
// ✅ Correct: JSON encoding for JavaScript
echo '<script>var name = ' . json_encode($name) . ';</script>';Problem: CSRF Token Validation Always Fails
Symptom: CSRF validation fails even with correct token
Cause: Session not started before token generation or validation
Solution: Ensure session is started:
// ✅ Always start session first
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$token = CSRFProtection::generateToken();Problem: Password Verification Fails
Symptom: password_verify() returns false even with correct password
Cause: Password hash was created with different algorithm or corrupted
Solution: Verify hash format and algorithm:
// Check hash info
$info = password_get_info($hash);
echo $info['algoName']; // Should be 'argon2id' or 'bcrypt'
// Rehash if needed
if (password_needs_rehash($hash, PASSWORD_ARGON2ID)) {
$newHash = password_hash($password, PASSWORD_ARGON2ID);
}Wrap-up
You've completed a comprehensive security chapter covering the most critical vulnerabilities and their mitigations. Here's what you've accomplished:
- ✅ Prevented SQL injection using prepared statements and parameterized queries
- ✅ Protected against XSS with proper output escaping and Content Security Policy
- ✅ Implemented CSRF protection with secure token generation and validation
- ✅ Secured authentication with strong password hashing and rate limiting
- ✅ Configured security headers to protect against common attack vectors
- ✅ Handled file uploads securely with content validation and safe storage
- ✅ Validated and sanitized input using PHP's filter functions
- ✅ Managed sessions securely with proper configuration and regeneration
- ✅ Handled errors safely without exposing sensitive information
- ✅ Applied OWASP Top 10 mitigations to your PHP applications
Security is an ongoing process, not a one-time task. Always validate input, escape output, use prepared statements, and keep your dependencies updated. When in doubt, use battle-tested framework features instead of building your own security mechanisms.
Further Reading
- OWASP Top 10 — The most critical web application security risks
- OWASP PHP Security Cheat Sheet — PHP-specific security guidelines
- PHP Manual: Password Hashing — Official password hashing documentation
- PHP Manual: Prepared Statements — PDO prepared statements guide
- Content Security Policy — CSP header documentation
- Paragon Initiative Security Guide — Comprehensive PHP security guide
Chapter Wrap-up Checklist
Before moving to the next chapter, ensure you can:
- [ ] Explain OWASP Top 10 vulnerabilities and their PHP mitigations
- [ ] Use prepared statements to prevent SQL injection
- [ ] Escape output appropriately for different contexts (HTML, JavaScript, URLs)
- [ ] Implement CSRF protection with secure tokens
- [ ] Hash passwords using
PASSWORD_ARGON2IDand verify them securely - [ ] Configure security headers for your application
- [ ] Validate and securely handle file uploads
- [ ] Validate and sanitize user input
- [ ] Configure sessions securely with proper cookie settings
- [ ] Handle errors without exposing sensitive information
- [ ] Apply security best practices to your PHP applications