1. php
  2. /security

PHP Security

Introduction to PHP Security

Security is paramount in web development. PHP applications are frequent targets for attacks due to their widespread use and common security mistakes. This guide covers essential security practices to protect your PHP applications from common vulnerabilities.

Understanding and implementing proper security measures is not optional—it's a fundamental responsibility of every developer.

Common Security Vulnerabilities

The OWASP Top 10

The Open Web Application Security Project (OWASP) maintains a list of the most critical web application security risks:

  1. Injection (SQL, NoSQL, OS, LDAP)
  2. Broken Authentication
  3. Sensitive Data Exposure
  4. XML External Entities (XXE)
  5. Broken Access Control
  6. Security Misconfiguration
  7. Cross-Site Scripting (XSS)
  8. Insecure Deserialization
  9. Using Components with Known Vulnerabilities
  10. Insufficient Logging & Monitoring

SQL Injection Prevention

SQL injection is one of the most common and dangerous attacks. Always use prepared statements.

Vulnerable Code (Never Do This)

<?php
// NEVER DO THIS - Vulnerable to SQL injection
$username = $_POST['username'];
$sql = "SELECT * FROM users WHERE username = '$username'";
$result = mysqli_query($connection, $sql);

// Attacker input: ' OR '1'='1' --
// Results in: SELECT * FROM users WHERE username = '' OR '1'='1' --'
?>

Secure Implementation with PDO

<?php
class SecureUserRepository {
    private $pdo;
    
    public function __construct($pdo) {
        $this->pdo = $pdo;
    }
    
    public function findByUsername($username) {
        $sql = "SELECT id, username, email FROM users WHERE username = ?";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$username]);
        
        return $stmt->fetch();
    }
    
    public function authenticate($username, $password) {
        $sql = "SELECT id, username, password FROM users WHERE username = ? AND active = 1";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$username]);
        
        $user = $stmt->fetch();
        
        if ($user && password_verify($password, $user['password'])) {
            return ['id' => $user['id'], 'username' => $user['username']];
        }
        
        return false;
    }
    
    public function searchUsers($searchTerm, $limit = 10) {
        $sql = "SELECT id, username, email FROM users 
                WHERE username LIKE ? OR email LIKE ? 
                LIMIT ?";
        
        $stmt = $this->pdo->prepare($sql);
        $searchParam = "%{$searchTerm}%";
        $stmt->execute([$searchParam, $searchParam, $limit]);
        
        return $stmt->fetchAll();
    }
}
?>

Cross-Site Scripting (XSS) Prevention

XSS occurs when user input is displayed without proper sanitization.

Output Encoding

<?php
class XSSProtection {
    public static function escape($data, $encoding = 'UTF-8') {
        return htmlspecialchars($data, ENT_QUOTES | ENT_HTML5, $encoding);
    }
    
    public static function escapeAttribute($data, $encoding = 'UTF-8') {
        return htmlspecialchars($data, ENT_QUOTES | ENT_HTML5, $encoding);
    }
    
    public static function escapeJavaScript($data) {
        return json_encode($data, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
    }
    
    public static function escapeCSS($data) {
        return preg_replace('/[^a-zA-Z0-9\-_]/', '\\\\$0', $data);
    }
    
    public static function escapeURL($data) {
        return urlencode($data);
    }
}

// Usage in templates
?>
<!DOCTYPE html>
<html>
<head>
    <title><?php echo XSSProtection::escape($pageTitle); ?></title>
</head>
<body>
    <h1><?php echo XSSProtection::escape($userInput); ?></h1>
    
    <!-- For attributes -->
    <input type="text" value="<?php echo XSSProtection::escapeAttribute($userValue); ?>">
    
    <!-- For JavaScript -->
    <script>
        var userData = <?php echo XSSProtection::escapeJavaScript($userData); ?>;
    </script>
    
    <!-- For URLs -->
    <a href="profile.php?id=<?php echo XSSProtection::escapeURL($userId); ?>">Profile</a>
</body>
</html>

Content Security Policy (CSP)

<?php
class CSPManager {
    private $policies = [];
    
    public function __construct() {
        // Default secure policy
        $this->policies = [
            'default-src' => ["'self'"],
            'script-src' => ["'self'"],
            'style-src' => ["'self'", "'unsafe-inline'"],
            'img-src' => ["'self'", 'data:', 'https:'],
            'font-src' => ["'self'"],
            'connect-src' => ["'self'"],
            'frame-ancestors' => ["'none'"],
            'base-uri' => ["'self'"],
            'form-action' => ["'self'"]
        ];
    }
    
    public function addSource($directive, $source) {
        if (!isset($this->policies[$directive])) {
            $this->policies[$directive] = [];
        }
        $this->policies[$directive][] = $source;
    }
    
    public function generateHeader() {
        $policy = [];
        foreach ($this->policies as $directive => $sources) {
            $policy[] = $directive . ' ' . implode(' ', $sources);
        }
        return implode('; ', $policy);
    }
    
    public function sendHeader() {
        header('Content-Security-Policy: ' . $this->generateHeader());
    }
}

// Usage
$csp = new CSPManager();
$csp->addSource('script-src', 'https://cdn.example.com');
$csp->sendHeader();
?>

Cross-Site Request Forgery (CSRF) Protection

CSRF attacks trick users into performing unintended actions.

CSRF Token Implementation

<?php
class CSRFProtection {
    private static $tokenName = 'csrf_token';
    
    public static function generateToken() {
        if (session_status() === PHP_SESSION_NONE) {
            session_start();
        }
        
        if (!isset($_SESSION[self::$tokenName])) {
            $_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
        }
        
        return $_SESSION[self::$tokenName];
    }
    
    public static function validateToken($token) {
        if (session_status() === PHP_SESSION_NONE) {
            session_start();
        }
        
        return isset($_SESSION[self::$tokenName]) && 
               hash_equals($_SESSION[self::$tokenName], $token);
    }
    
    public static function getTokenField() {
        $token = self::generateToken();
        return '<input type="hidden" name="' . self::$tokenName . '" value="' . $token . '">';
    }
    
    public static function validateRequest() {
        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            $token = $_POST[self::$tokenName] ?? '';
            if (!self::validateToken($token)) {
                http_response_code(403);
                die('CSRF token validation failed');
            }
        }
    }
}

// Usage in forms
?>
<form method="POST" action="update-profile.php">
    <?php echo CSRFProtection::getTokenField(); ?>
    <input type="email" name="email" required>
    <button type="submit">Update</button>
</form>

<?php
// In the processing script
CSRFProtection::validateRequest();
// Process form safely...
?>

Input Validation and Sanitization

Comprehensive Input Validator

<?php
class InputValidator {
    private $errors = [];
    
    public function validate($data, $rules) {
        $this->errors = [];
        
        foreach ($rules as $field => $fieldRules) {
            $value = $data[$field] ?? null;
            
            foreach ($fieldRules as $rule => $parameter) {
                if (!$this->applyRule($field, $value, $rule, $parameter)) {
                    break; // Stop on first error for this field
                }
            }
        }
        
        return empty($this->errors);
    }
    
    private function applyRule($field, $value, $rule, $parameter) {
        switch ($rule) {
            case 'required':
                if ($parameter && empty($value)) {
                    $this->errors[$field] = ucfirst($field) . " is required";
                    return false;
                }
                break;
                
            case 'email':
                if (!empty($value) && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
                    $this->errors[$field] = "Invalid email format";
                    return false;
                }
                break;
                
            case 'min_length':
                if (!empty($value) && strlen($value) < $parameter) {
                    $this->errors[$field] = ucfirst($field) . " must be at least {$parameter} characters";
                    return false;
                }
                break;
                
            case 'max_length':
                if (!empty($value) && strlen($value) > $parameter) {
                    $this->errors[$field] = ucfirst($field) . " must not exceed {$parameter} characters";
                    return false;
                }
                break;
                
            case 'numeric':
                if (!empty($value) && !is_numeric($value)) {
                    $this->errors[$field] = ucfirst($field) . " must be numeric";
                    return false;
                }
                break;
                
            case 'regex':
                if (!empty($value) && !preg_match($parameter, $value)) {
                    $this->errors[$field] = ucfirst($field) . " format is invalid";
                    return false;
                }
                break;
                
            case 'in':
                if (!empty($value) && !in_array($value, $parameter)) {
                    $this->errors[$field] = ucfirst($field) . " has invalid value";
                    return false;
                }
                break;
        }
        
        return true;
    }
    
    public function getErrors() {
        return $this->errors;
    }
    
    public static function sanitize($data, $type = 'string') {
        switch ($type) {
            case 'email':
                return filter_var($data, FILTER_SANITIZE_EMAIL);
            case 'url':
                return filter_var($data, FILTER_SANITIZE_URL);
            case 'int':
                return filter_var($data, FILTER_SANITIZE_NUMBER_INT);
            case 'float':
                return filter_var($data, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION);
            case 'string':
            default:
                return htmlspecialchars(trim($data), ENT_QUOTES, 'UTF-8');
        }
    }
}

// Usage
$validator = new InputValidator();

$rules = [
    'username' => ['required' => true, 'min_length' => 3, 'max_length' => 20],
    'email' => ['required' => true, 'email' => true],
    'password' => ['required' => true, 'min_length' => 8],
    'age' => ['numeric' => true],
    'country' => ['in' => ['US', 'CA', 'UK', 'AU']]
];

if ($validator->validate($_POST, $rules)) {
    // Process valid data
    $username = InputValidator::sanitize($_POST['username']);
    $email = InputValidator::sanitize($_POST['email'], 'email');
} else {
    $errors = $validator->getErrors();
}
?>

Authentication and Session Security

Secure Authentication System

<?php
class SecureAuth {
    private $pdo;
    private $sessionTimeout = 1800; // 30 minutes
    
    public function __construct($pdo) {
        $this->pdo = $pdo;
        $this->configureSession();
    }
    
    private function configureSession() {
        // Secure session configuration
        ini_set('session.cookie_httponly', 1);
        ini_set('session.cookie_secure', 1);
        ini_set('session.use_strict_mode', 1);
        ini_set('session.cookie_samesite', 'Strict');
        
        session_start();
        
        // Regenerate session ID periodically
        if (!isset($_SESSION['created'])) {
            $_SESSION['created'] = time();
        } elseif (time() - $_SESSION['created'] > 300) { // 5 minutes
            session_regenerate_id(true);
            $_SESSION['created'] = time();
        }
    }
    
    public function login($username, $password) {
        // Rate limiting check
        if ($this->isRateLimited($username)) {
            return ['success' => false, 'message' => 'Too many login attempts. Try again later.'];
        }
        
        $sql = "SELECT id, username, password, failed_attempts, last_failed_login 
                FROM users WHERE username = ? AND active = 1";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$username]);
        $user = $stmt->fetch();
        
        if ($user && password_verify($password, $user['password'])) {
            // Reset failed attempts on successful login
            $this->resetFailedAttempts($user['id']);
            
            // Set session data
            $_SESSION['user_id'] = $user['id'];
            $_SESSION['username'] = $user['username'];
            $_SESSION['logged_in'] = true;
            $_SESSION['last_activity'] = time();
            
            // Update last login
            $this->updateLastLogin($user['id']);
            
            return ['success' => true, 'user' => ['id' => $user['id'], 'username' => $user['username']]];
        } else {
            // Record failed attempt
            if ($user) {
                $this->recordFailedAttempt($user['id']);
            }
            
            return ['success' => false, 'message' => 'Invalid credentials'];
        }
    }
    
    public function logout() {
        $_SESSION = [];
        session_destroy();
        
        // Delete session cookie
        if (isset($_COOKIE[session_name()])) {
            setcookie(session_name(), '', time() - 3600, '/');
        }
    }
    
    public function isLoggedIn() {
        if (!isset($_SESSION['logged_in'], $_SESSION['last_activity'])) {
            return false;
        }
        
        // Check for session timeout
        if (time() - $_SESSION['last_activity'] > $this->sessionTimeout) {
            $this->logout();
            return false;
        }
        
        $_SESSION['last_activity'] = time();
        return true;
    }
    
    public function requireAuth() {
        if (!$this->isLoggedIn()) {
            header('Location: /login.php');
            exit;
        }
    }
    
    private function isRateLimited($username) {
        $sql = "SELECT failed_attempts, last_failed_login FROM users WHERE username = ?";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$username]);
        $user = $stmt->fetch();
        
        if ($user && $user['failed_attempts'] >= 5) {
            $timeSinceLastFail = time() - strtotime($user['last_failed_login']);
            return $timeSinceLastFail < 900; // 15 minutes lockout
        }
        
        return false;
    }
    
    private function recordFailedAttempt($userId) {
        $sql = "UPDATE users SET failed_attempts = failed_attempts + 1, 
                last_failed_login = NOW() WHERE id = ?";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$userId]);
    }
    
    private function resetFailedAttempts($userId) {
        $sql = "UPDATE users SET failed_attempts = 0, last_failed_login = NULL WHERE id = ?";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$userId]);
    }
    
    private function updateLastLogin($userId) {
        $sql = "UPDATE users SET last_login = NOW() WHERE id = ?";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$userId]);
    }
}
?>

File Upload Security

Secure File Upload Handler

<?php
class SecureFileUpload {
    private $allowedTypes;
    private $maxSize;
    private $uploadPath;
    
    public function __construct($uploadPath = 'uploads/', $maxSize = 2097152) { // 2MB default
        $this->uploadPath = $uploadPath;
        $this->maxSize = $maxSize;
        $this->allowedTypes = [
            'image/jpeg' => 'jpg',
            'image/png' => 'png',
            'image/gif' => 'gif',
            'application/pdf' => 'pdf',
            'text/plain' => 'txt'
        ];
        
        $this->ensureUploadDirectory();
    }
    
    public function upload($fileField) {
        if (!isset($_FILES[$fileField])) {
            return ['success' => false, 'message' => 'No file uploaded'];
        }
        
        $file = $_FILES[$fileField];
        
        // Check for upload errors
        if ($file['error'] !== UPLOAD_ERR_OK) {
            return ['success' => false, 'message' => 'Upload failed with error code: ' . $file['error']];
        }
        
        // Validate file size
        if ($file['size'] > $this->maxSize) {
            return ['success' => false, 'message' => 'File too large'];
        }
        
        // Validate MIME type
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mimeType = finfo_file($finfo, $file['tmp_name']);
        finfo_close($finfo);
        
        if (!array_key_exists($mimeType, $this->allowedTypes)) {
            return ['success' => false, 'message' => 'File type not allowed'];
        }
        
        // Generate secure filename
        $extension = $this->allowedTypes[$mimeType];
        $filename = $this->generateSecureFilename($extension);
        $destination = $this->uploadPath . $filename;
        
        // Additional security checks for images
        if (strpos($mimeType, 'image/') === 0) {
            if (!$this->isValidImage($file['tmp_name'])) {
                return ['success' => false, 'message' => 'Invalid image file'];
            }
        }
        
        // Move file
        if (move_uploaded_file($file['tmp_name'], $destination)) {
            // Set restrictive permissions
            chmod($destination, 0644);
            
            return [
                'success' => true,
                'filename' => $filename,
                'path' => $destination,
                'size' => $file['size'],
                'type' => $mimeType
            ];
        }
        
        return ['success' => false, 'message' => 'Failed to move uploaded file'];
    }
    
    private function generateSecureFilename($extension) {
        return bin2hex(random_bytes(16)) . '.' . $extension;
    }
    
    private function isValidImage($filePath) {
        $imageInfo = getimagesize($filePath);
        return $imageInfo !== false;
    }
    
    private function ensureUploadDirectory() {
        if (!is_dir($this->uploadPath)) {
            mkdir($this->uploadPath, 0755, true);
        }
        
        // Create .htaccess to prevent direct execution
        $htaccessPath = $this->uploadPath . '.htaccess';
        if (!file_exists($htaccessPath)) {
            file_put_contents($htaccessPath, "Options -Indexes\nOptions -ExecCGI\nAddHandler cgi-script .php .pl .py .jsp .asp .sh .cgi");
        }
    }
}
?>

Security Headers

Comprehensive Security Headers

<?php
class SecurityHeaders {
    public static function setSecurityHeaders() {
        // Prevent MIME type sniffing
        header('X-Content-Type-Options: nosniff');
        
        // Enable XSS protection
        header('X-XSS-Protection: 1; mode=block');
        
        // Prevent clickjacking
        header('X-Frame-Options: DENY');
        
        // Enforce HTTPS
        header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
        
        // Referrer policy
        header('Referrer-Policy: strict-origin-when-cross-origin');
        
        // Feature policy
        header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
        
        // Remove server information
        header_remove('X-Powered-By');
        header_remove('Server');
    }
    
    public static function setCSP($policy = null) {
        if (!$policy) {
            $policy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';";
        }
        
        header('Content-Security-Policy: ' . $policy);
    }
    
    public static function setAll($cspPolicy = null) {
        self::setSecurityHeaders();
        self::setCSP($cspPolicy);
    }
}

// Use at the beginning of your application
SecurityHeaders::setAll();
?>

Error Handling and Logging

Secure Error Handling

<?php
class SecureErrorHandler {
    private $logPath;
    private $isProduction;
    
    public function __construct($logPath = 'logs/security.log', $isProduction = true) {
        $this->logPath = $logPath;
        $this->isProduction = $isProduction;
        
        // Set error handling in production
        if ($this->isProduction) {
            ini_set('display_errors', 0);
            ini_set('log_errors', 1);
            ini_set('error_log', $this->logPath);
        }
        
        set_error_handler([$this, 'handleError']);
        set_exception_handler([$this, 'handleException']);
    }
    
    public function handleError($severity, $message, $file, $line) {
        $errorInfo = [
            'type' => 'PHP Error',
            'severity' => $severity,
            'message' => $message,
            'file' => $file,
            'line' => $line,
            'timestamp' => date('Y-m-d H:i:s'),
            'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
            'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
        ];
        
        $this->logSecurityEvent($errorInfo);
        
        if ($this->isProduction) {
            // Show generic error message
            include 'error_pages/500.html';
            exit;
        }
    }
    
    public function handleException($exception) {
        $errorInfo = [
            'type' => 'PHP Exception',
            'message' => $exception->getMessage(),
            'file' => $exception->getFile(),
            'line' => $exception->getLine(),
            'trace' => $exception->getTraceAsString(),
            'timestamp' => date('Y-m-d H:i:s'),
            'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
        ];
        
        $this->logSecurityEvent($errorInfo);
        
        if ($this->isProduction) {
            include 'error_pages/500.html';
            exit;
        }
    }
    
    public function logSecurityEvent($eventData) {
        $logEntry = json_encode($eventData) . "\n";
        file_put_contents($this->logPath, $logEntry, FILE_APPEND | LOCK_EX);
    }
    
    public function logSuspiciousActivity($description, $additionalData = []) {
        $eventData = array_merge([
            'type' => 'Suspicious Activity',
            'description' => $description,
            'timestamp' => date('Y-m-d H:i:s'),
            'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
            'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
            'request_uri' => $_SERVER['REQUEST_URI'] ?? 'unknown'
        ], $additionalData);
        
        $this->logSecurityEvent($eventData);
    }
}

// Initialize secure error handling
$errorHandler = new SecureErrorHandler('logs/security.log', true);

// Log suspicious activity
$errorHandler->logSuspiciousActivity('Multiple failed login attempts', [
    'username' => $username,
    'attempts' => $attemptCount
]);
?>

Explore these related security topics:

Summary

PHP security requires a multi-layered approach:

  • Input Validation: Always validate and sanitize user input
  • SQL Injection Prevention: Use prepared statements exclusively
  • XSS Protection: Properly encode output and implement CSP
  • CSRF Prevention: Use tokens for state-changing operations
  • Authentication Security: Implement secure session management
  • File Upload Security: Validate file types and contents
  • Security Headers: Implement comprehensive security headers
  • Error Handling: Log security events without exposing sensitive information

Security is an ongoing process, not a one-time implementation. Stay updated with the latest security practices, regularly audit your code, and consider security in every development decision.