Securing PHP Applications: The Input Checklist

14 min read 2,795 words
Securing PHP Applications: The Input Validation and Output Encoding Checklist featured image

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.

Security-conscious PHP development requires attention to every point where external data enters your system. For a broader view of common vulnerabilities affecting PHP applications, the guide on PHP security for business websites covers the foundational risks you should understand before building any 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. It removes the need to anticipate every possible invalid input and instead focuses on defining what is permitted.

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,
]);

With this configuration in place, even if you accidentally write string concatenation in a query, the database driver will reject it because native prepared statements do not support mixing query structure with dynamic values.

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. However, when building custom templates or working with raw PHP, you must apply encoding manually for each output point.

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...
}

CSRF protection should be applied to every state-changing request, including POST, PUT, PATCH, and DELETE operations. GET requests should not modify data, but endpoints that return sensitive information based on parameters still need protection against CSRF in some scenarios.

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.

Rehashing and Algorithm Upgrades

Over time, hardware improvements may make existing hash costs insufficient. PHP's password_needs_rehash() function checks whether a stored hash uses the current preferred algorithm and cost settings. When a user logs in successfully, you can transparently upgrade their hash to the newer standard without requiring any action from the user.

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.

Session Fixation and Regeneration

Session fixation attacks occur when an attacker sets or obtains a valid session identifier before the user logs in. After authentication, the attacker uses the same identifier to access the authenticated session. Calling session_regenerate_id(true) after successful login invalidates the old session ID and issues a new one, preventing this attack vector.

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.

Content Security Policy Considerations

A strict Content Security Policy can break third-party scripts, inline styles, and inline event handlers. Start with a restrictive policy and adjust it based on what your application actually needs. Monitor your browser console for CSP violations during development to identify what needs adjustment before deploying to production.

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. Running this command as part of your deployment process helps catch vulnerable dependencies before they reach production.

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.

Rate Limiting and API Protection

Applications that expose APIs or handle high traffic volumes benefit from rate limiting to prevent abuse. Without rate limiting, attackers can use automated tools to brute force credentials, scrape content, or overwhelm your server with requests. Implementing rate limiting at the application level adds a layer of protection that complements server-level controls.

PHP applications can implement rate limiting using caching mechanisms like Redis to track request counts per IP address or user session. Setting appropriate limits for different endpoint types prevents abuse while allowing legitimate traffic to flow normally.

The article on Redis-based rate limiting in PHP covers implementation approaches for protecting your application endpoints from automated abuse.

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. Even well-written applications can contain logical flaws that require human review to discover.

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.

Frequently Asked Questions

What is the difference between input validation and output encoding?
Input validation checks data when it enters your application, ensuring it matches expected formats before processing. Output encoding converts data when it leaves your application, typically when displaying it in a web page or API response. Both are essential but serve different purposes: validation prevents bad data from entering your system, while encoding prevents that data from causing harm when displayed.
Can I rely solely on prepared statements to prevent SQL injection?
Prepared statements are the most effective protection against SQL injection when used consistently for all queries. However, they must be used correctly with bound parameters rather than concatenated values. Some edge cases, such as dynamic table or column names, cannot use prepared statements and require additional safeguards. A defence-in-depth approach that combines prepared statements with input validation provides stronger protection.
How often should I update PHP and its extensions?
PHP releases security updates regularly, and you should update to supported versions promptly. PHP 8.0 reached end of life in November 2023, which means security patches are no longer released for it. Running an unsupported PHP version leaves your application vulnerable to known issues that have been addressed in newer releases. Check the official PHP supported versions page to confirm your current version is still receiving security support.
Are security headers necessary if my code is already secure?
Security headers provide protection at the browser level that complements your application code. Even if your code is secure today, new vulnerabilities may be discovered later. Security headers protect against classes of attacks that rely on browser behaviour rather than application bugs. They also provide protection against vulnerabilities in client-side code, third-party scripts, and dependencies that you may not control directly.
What should I do if I discover a security vulnerability in my PHP application?
Isolate the affected functionality if possible while you investigate the scope of the vulnerability. Check your application's logs for any signs of exploitation. Apply an immediate fix if one is available, or disable the affected feature temporarily. Once the vulnerability is patched, review similar code patterns throughout your application for the same issue. Consider whether you need to notify affected users if sensitive data may have been accessed.
Is password_hash compatible with existing user passwords hashed using older methods?
Yes, password_verify() can verify passwords hashed with older methods if those hashes are stored as-is. When a user successfully logs in with an older hash, you can rehash their password using password_needs_rehash() and update the stored hash. This gradual migration approach lets you upgrade to secure hashing without forcing all users to reset their passwords simultaneously.
How does the SameSite cookie attribute affect CSRF protection?
The SameSite attribute controls when browsers send cookies with cross-site requests. Setting SameSite=Strict prevents the browser from sending the session cookie with any cross-site request, which blocks most CSRF attacks automatically. However, this strict behaviour can also break legitimate cross-site functionality, so some applications use SameSite=Lax to allow top-level navigation while still blocking CSRF-prone request types like POST.
What is the risk of storing file uploads inside the web root?
Files stored inside the web root can potentially be executed by the server if they have executable extensions like .php or .phtml. Even if you rename files, a misconfigured server might still execute them. Storing uploads outside the web root prevents direct URL access entirely. If users need to access uploaded files, serve them through a PHP script that validates permissions and sets the correct Content-Type header.