Input Validation: Your First Line of Defence

Every piece of data that enters a PHP application carries potential risk. User input from form fields, URL parameters, cookies, API calls, and even database results can be manipulated by attackers. Input validation is the process of checking that data matches expected formats and values before your application processes it.

The golden rule is simple: treat all external data as untrusted until proven otherwise. This includes $_GET, $_POST, $_COOKIE, $_REQUEST, $_SERVER variables, and any data retrieved from third-party services or databases. Building a strict validation approach early in your development process prevents vulnerabilities from reaching deeper into your application.

Strict Type Checking and Format Validation

Define exactly what valid input looks like for each field and reject everything that does not match. Allowlists are more effective than blocklists because they only permit known-good values rather than trying to identify every possible bad value. When validating identifiers, numeric fields, email addresses, or URL slugs, use PHP's type system and built-in validation functions to enforce strict rules.

function validateId(string|int $id): int
{
    if (!is_numeric($id)) {
        throw new InvalidArgumentException('ID must be numeric');
    }
    $id = (int) $id;
    if ($id <= 0) {
        throw new InvalidArgumentException('ID must be a positive integer');
    }
    return $id;
}

function validateEmail(string $email): string
{
    $email = trim($email);
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        throw new InvalidArgumentException('Invalid email address');
    }
    return $email;
}

function validateSlug(string $slug): string
{
    if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug)) {
        throw new InvalidArgumentException('Invalid slug format');
    }
    return $slug;
}

These validation functions throw exceptions for invalid input, which stops malformed data from reaching your business logic. Catching these exceptions at the controller or entry point level lets you return clear error messages to users without exposing sensitive application details.

Allowlist Validation for Known Values

When a parameter accepts only a limited set of values, validate against the allowed list rather than trying to filter out dangerous inputs. An allowlist approach is safer because it assumes everything is invalid unless explicitly permitted.

$allowedStatuses = ['draft', 'published', 'archived'];

if (!in_array($status, $allowedStatuses, true)) {
    throw new InvalidArgumentException('Invalid status value');
}

This pattern is particularly useful for filtering query parameters, filter options, and any field where the valid choices are known in advance.

SQL Injection Prevention

SQL injection remains one of the most damaging vulnerabilities in PHP applications. It occurs when user input is concatenated directly into SQL queries, allowing attackers to manipulate the intended query structure. A successful SQL injection can expose sensitive data, modify database records, or in some cases execute operating system commands on the database server.

Prepared statements separate query structure from data, preventing attackers from altering the query logic. This approach ensures that user input is always treated as literal data rather than executable SQL code. Using prepared statements for every database query is the most effective way to eliminate SQL injection risks.

// Unsafe — never concatenate user input into queries
$sql = "SELECT * FROM users WHERE id = " . $_GET['id'];
$pdo->query($sql);

// Safe — prepared statement with bound parameters
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute(['id' => $_GET['id']]);
// Unsafe — string concatenation allows injection
$sql = "SELECT * FROM articles WHERE slug = '" . $_GET['slug'] . "'";

// Safe — prepared statement
$stmt = $pdo->prepare('SELECT * FROM articles WHERE slug = :slug');
$stmt->execute(['slug' => $_GET['slug']]);

For a more detailed guide on identifying and fixing SQL injection vulnerabilities in PHP applications, see the article on SQL injection checks for PHP websites.

PDO Configuration for Native Prepared Statements

When using PDO, ensure that emulated prepared statements are disabled. This forces PDO to use the database's native prepared statement mechanism for all queries, providing consistent protection across different database drivers.

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_EMULATE_PREPARES => false,
]);

Output Encoding: Preventing Cross-Site Scripting

Cross-site scripting (XSS) vulnerabilities occur when user input is displayed without proper encoding, allowing attackers to inject malicious scripts into web pages. Output encoding converts dangerous characters into their safe HTML entity equivalents, ensuring that browser rendering treats user data as text rather than executable code.

The encoding method depends on the output context. HTML encoding works for web page content, URL encoding for query parameters, and JavaScript encoding for inline scripts. Choosing the right encoding method for each context is essential because improper encoding can still allow XSS attacks.

// HTML encode output in templates to prevent XSS
<p><?php echo htmlspecialchars($userComment, ENT_QUOTES, 'UTF-8'); ?></p>
// For JSON output, ensure strings are properly encoded
header('Content-Type: application/json');
echo json_encode($data, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
// For inline JavaScript, use json_encode with HTML-safe encoding
<script>
var userName = <?php echo json_encode($userName, JSON_HEX_TAG); ?>;
</script>

Using a templating engine that handles encoding automatically reduces the risk of forgetting to encode output in specific contexts. Many modern PHP frameworks include automatic output encoding as a core feature.

Cross-Site Request Forgery Protection

Cross-site request forgery (CSRF) attacks trick authenticated users into performing unintended actions on a web application. Browsers automatically send cookies with every request to a domain, which means if a user is logged in and visits a malicious page, that page can trigger actions on your application without the user's knowledge.

CSRF tokens break this attack pattern by requiring a secret, unpredictable value that the attacker cannot guess or obtain from the target page. Each form submission must include a valid token that the server verifies before processing the request.

session_start([
    'cookie_httponly' => true,
    'cookie_secure' => true,
    'cookie_samesite' => 'Strict',
]);

function generateCsrfToken(): string
{
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

function validateCsrfToken(string $token): bool
{
    return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
<form method="POST" action="/update-profile">
    <input type="hidden" name="csrf_token" value="<?php echo generateCsrfToken(); ?>">
    <input type="email" name="email" value="<?php echo htmlspecialchars($email); ?>">
    <button type="submit">Update</button>
</form>
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!validateCsrfToken($_POST['csrf_token'] ?? '')) {
        http_response_code(403);
        exit('Invalid request');
    }
    // Process form...
}

For a comprehensive guide to implementing CSRF protection in PHP forms, see the article on CSRF protection with token implementation.

Secure Password Storage

Passwords must never be stored in plain text or using weak hashing algorithms. When a data breach exposes poorly hashed passwords, attackers can quickly crack them using precomputed tables or brute force methods. PHP provides built-in functions designed specifically for secure password hashing that handle salt generation and cost factor selection automatically.

PHP's password_hash() and password_verify() functions use bcrypt by default and support Argon2id, which is currently the recommended algorithm for password storage. These functions also handle salt management internally, eliminating the risk of using the same salt across multiple passwords.

// When creating a user account
$hash = password_hash($password, PASSWORD_ARGON2ID);

// When verifying a login attempt
if (password_verify($password, $storedHash)) {
    // Password correct
    if (password_needs_rehash($storedHash, PASSWORD_ARGON2ID)) {
        // Rehash if the algorithm or cost factor has been updated
        $newHash = password_hash($password, PASSWORD_ARGON2ID);
        // Update stored hash in database
    }
}

Algorithms like MD5 and SHA1 are designed for speed, which makes them particularly vulnerable to brute force attacks. Even when these algorithms are used with a salt, modern hardware can crack such hashes in minutes. Always use PASSWORD_ARGON2ID, PASSWORD_ARGON2I, or PASSWORD_BCRYPT for password storage.

Session Security Configuration

PHP sessions store user authentication state on the server, with a session identifier stored in a cookie on the client. Misconfigured sessions can allow session hijacking, where attackers steal a valid session identifier to impersonate the legitimate user. Configuring session settings correctly is essential for maintaining authentication security.

session_start([
    'cookie_httponly' => true,   // Prevents JavaScript access to session cookie
    'cookie_secure' => true,      // Only send cookie over HTTPS
    'cookie_samesite' => 'Strict', // Controls cross-site cookie behavior
    'use_strict_mode' => true,    // Reject uninitialised session IDs
]);

// Regenerate session ID after login to prevent session fixation
if ($loginSuccess) {
    session_regenerate_id(true);
    $_SESSION['user_id'] = $userId;
}

// Verify session is valid on each protected request
if (!isset($_SESSION['user_id'])) {
    header('Location: /login');
    exit;
}

Session cookies should always have the HttpOnly flag set to prevent JavaScript from reading them, reducing the risk from XSS attacks. The Secure flag ensures cookies are only transmitted over HTTPS connections. The SameSite attribute provides protection against CSRF attacks by controlling when cookies are sent with cross-site requests.

Secure File Upload Handling

File upload functionality introduces significant security risks if not handled carefully. Attackers can upload malicious files that execute on the server, corrupt data, or compromise the underlying system. Every aspect of an uploaded file should be validated, including its size, type, content, and destination filename.

Never trust the filename, MIME type, or extension provided by the client. These values can be easily manipulated. Server-side validation using PHP's finfo class examines the actual file content to determine its true MIME type.

function handleUpload(array $file, string $uploadDir): string
{
    // Validate file size
    if ($file['size'] > 5 * 1024 * 1024) { // 5 MB limit
        throw new InvalidArgumentException('File exceeds size limit');
    }

    // Validate actual MIME type by examining file content
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mimeType = $finfo->file($file['tmp_name']);
    $allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];

    if (!in_array($mimeType, $allowedMimes, true)) {
        throw new InvalidArgumentException('File type not permitted');
    }

    // Map MIME type to extension and generate secure filename
    $extension = match($mimeType) {
        'image/jpeg' => 'jpg',
        'image/png' => 'png',
        'image/gif' => 'gif',
        'application/pdf' => 'pdf',
    };

    // Generate random filename to prevent path traversal and extension tricks
    $newFilename = bin2hex(random_bytes(16)) . '.' . $extension;

    // Store files outside the web root if possible
    $destination = rtrim($uploadDir, '/') . '/' . $newFilename;

    if (!move_uploaded_file($file['tmp_name'], $destination)) {
        throw new RuntimeException('Upload processing failed');
    }

    return $newFilename;
}

Storing uploaded files outside the web root prevents direct access through the browser, which would allow execution of malicious scripts. If files must be accessible through the web, serve them through a PHP script that validates permissions and sets appropriate Content-Type headers.

Security Headers for Browser Protection

Security headers instruct browsers to enforce additional protections at the client level. These headers provide defence in depth by blocking common attack vectors even if your application code contains vulnerabilities. Configuring security headers in your front controller or application bootstrap ensures they are applied to every response.

// Prevent MIME type sniffing
header('X-Content-Type-Options: nosniff');

// Block embedding in iframes to prevent clickjacking
header('X-Frame-Options: DENY');

// Enable XSS filtering in older browsers
header('X-XSS-Protection: 1; mode=block');

// Control referrer information sent with requests
header('Referrer-Policy: strict-origin-when-cross-origin');

// Content Security Policy
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';");

// Enforce HTTPS in modern browsers
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');

These headers work together to prevent MIME type confusion attacks, block clickjacking attempts, control referrer information leakage, and enforce secure connections. The Content-Security-Policy header is particularly powerful but requires careful configuration to avoid breaking legitimate functionality.

Framework and Library Security Practices

Using well-maintained PHP frameworks and libraries reduces the burden of implementing security features from scratch. Modern frameworks typically include built-in protection against SQL injection, XSS, CSRF, and other common vulnerabilities. However, framework usage does not guarantee security on its own.

Keeping dependencies updated is critical. Many security vulnerabilities are discovered in third-party packages, and updates are released to address them. Regularly reviewing and updating your application's dependencies using Composer helps prevent known vulnerabilities from being exploited.

composer audit

The composer audit command checks your project's dependencies against the Friends of PHP security advisory database, reporting any known vulnerabilities in packages you use.

For a broader overview of security risks affecting business web applications, the OWASP Top 10 guide for business web applications covers the most common vulnerabilities and their mitigations in detail.

When to Seek Professional Security Help

While this checklist covers fundamental security practices, complex applications may have subtle vulnerabilities that are difficult to identify without specialist knowledge. If your application handles sensitive data, processes payments, or serves a significant number of users, a professional security review can identify issues that automated tools miss.

Regular security audits, penetration testing, and code reviews help maintain a strong security posture over time. Security is not a one-time configuration but an ongoing process of monitoring, updating, and improving your application's defences.

If you need help reviewing your PHP application's security setup, prepare details about your application structure, hosting environment, and any specific concerns before getting in touch.