PHP Form Validation: The Server-Side Security Guide

Validation is your first line of defence. CAPTCHA is your second. A form without proper server-side validation is vulnerable to XSS, SQL injection, and spam — regardless of what CAPTCHA you use. This guide covers secure PHP form validation in 2026, with a complete contact form example you can use in production.

Server-Side vs Client-Side: Only One Protects You

Client-side validation (HTML5 required attributes, JavaScript checks) improves UX by catching mistakes before the form submits. But it provides zero protection against malicious attacks. Anyone can disable JavaScript, modify the DOM, or POST directly to your endpoint with cURL.

Server-side validation in PHP is the only validation that matters for security. Every check must happen on the server. Client-side validation is helpful for UX — but never rely on it for safety.

Validation vs Sanitisation vs Output Escaping

These three operations serve different purposes. Confusing them is how vulnerabilities happen:

Operation Purpose When Example
Validation Reject input that doesn't match expected format Immediately on receipt Is this a valid email? Is this integer between 1–100?
Storage Save raw, validated data safely using prepared statements When writing to DB PDO with bound parameters — data goes in unaltered
Output escaping Prevent injection when displaying data At the moment of output htmlspecialchars() when rendering in HTML

Store raw data. Escape on output. Validating an email address doesn't make it safe for display. Escaping for HTML doesn't protect your database. Each layer solves a specific problem — you need all three.

Validating Input with filter_input()

PHP's filter_input() validates and filters input from superglobals in one call:

<?php
// PHP 8.0+ — Validate common form fields

// Email — validates format (RFC 5322, mostly)
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);

// Integer within a range
$age = filter_input(INPUT_POST, 'age', FILTER_VALIDATE_INT, [
    'options' => ['min_range' => 18, 'max_range' => 120],
]);

// URL — validates scheme and format
$website = filter_input(INPUT_POST, 'website', FILTER_VALIDATE_URL);

// Returns false on validation failure, null if the field doesn't exist
if ($email === false) {
    $errors[] = 'Enter a valid email address.';
}
if ($email === null) {
    $errors[] = 'Email is required.';
}

For free-text fields like names and messages, don't sanitise on input — just trim() and validate length. Store the raw text and escape it at output time.

Validating Select, Radio, and Checkbox Values

Never trust that a <select> or radio button submitted one of your predefined options. Validate against a whitelist:

<?php
$allowedSubjects = ['sales', 'support', 'billing', 'other'];

$subject = $_POST['subject'] ?? '';
if (!in_array($subject, $allowedSubjects, strict: true)) {
    $errors[] = 'Select a valid subject.';
}

Output Escaping with htmlspecialchars()

When displaying user input in HTML, always escape it. The bare htmlspecialchars($input) call most tutorials show is incomplete. You need three parameters:

<?php
// Correct usage — always specify flags and encoding
$safe = htmlspecialchars($input, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

// ENT_QUOTES: escapes both double AND single quotes
//   Without it, <input value='$input'> is vulnerable
// ENT_SUBSTITUTE: replaces invalid byte sequences instead of returning empty string
// 'UTF-8': explicit charset matching your document's encoding

Create a helper so you don't repeat the parameters everywhere:

<?php
function esc(string $input): string
{
    return htmlspecialchars($input, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

Context matters: htmlspecialchars() protects HTML body text and attribute values. It does not protect against injection in JavaScript blocks, CSS, or URLs. For JavaScript, use json_encode(). For URLs, use urlencode(). For full auto-escaping, use a templating engine like Twig or Blade.

Database Safety: Prepared Statements

Prepared statements are the only reliable defence against SQL injection. Validation alone is not enough — even a valid email address could be crafted to exploit an unparameterised query.

<?php
$pdo = new PDO(
    dsn: 'mysql:host=localhost;dbname=app;charset=utf8mb4',
    username: $dbUser,
    password: $dbPass,
    options: [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false, // Real server-side binding
    ],
);

// Parameterised query — $name and $email are stored as raw text
$stmt = $pdo->prepare('INSERT INTO messages (name, email, message) VALUES (:name, :email, :message)');
$stmt->execute([
    ':name'    => $name,
    ':email'   => $email,
    ':message' => $message,
]);

What prepared statements can't protect: table names, column names, and ORDER BY directions can't be parameterised. Validate these against a whitelist:

<?php
$allowedColumns = ['name', 'email', 'created_at'];
$sortColumn = in_array($_GET['sort'] ?? '', $allowedColumns, strict: true)
    ? $_GET['sort']
    : 'created_at';

$stmt = $pdo->query("SELECT * FROM messages ORDER BY {$sortColumn} ASC");

Common Pitfalls

The filter_input(INPUT_SERVER) Bug

filter_input(INPUT_SERVER, ...) and filter_input(INPUT_ENV, ...) return null on many FastCGI setups (nginx + php-fpm, most shared hosting). This is a documented PHP bug open for over 15 years. For server variables, use $_SERVER with filter_var() instead:

<?php
// Breaks on FastCGI:
$method = filter_input(INPUT_SERVER, 'REQUEST_METHOD');

// Works everywhere:
$method = filter_var($_SERVER['REQUEST_METHOD'] ?? '', FILTER_SANITIZE_SPECIAL_CHARS);

Regex Validation with preg_match()

filter_input() can't handle every format. For phone numbers, postcodes, or usernames, use preg_match():

<?php
// Alphanumeric username, 3–20 characters
$username = trim($_POST['username'] ?? '');
if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $username)) {
    $errors[] = 'Username must be 3–20 alphanumeric characters.';
}

Complete Example: Secure Contact Form

Here's a production-ready contact form with validation, CSRF protection, a honeypot field, and safe output escaping. This is the foundation you'd add a CAPTCHA on top of.

The form has four defence layers — read through the comments to understand each one.

<?php
// PHP 8.0+ — Secure contact form with layered protection
// Save as contact.php

session_start();

$errors  = [];
$success = false;
$old     = ['name' => '', 'email' => '', 'message' => ''];

// Helper for output escaping — used in the HTML below
function esc(string $input): string
{
    return htmlspecialchars($input, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

// ── Generate CSRF token and form load timestamp ────────────
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
if (empty($_SESSION['form_loaded_at'])) {
    $_SESSION['form_loaded_at'] = time();
}

// ── Process form submission ────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    // Layer 1: CSRF check — reject forged cross-site requests
    $token = $_POST['csrf_token'] ?? '';
    if (!hash_equals($_SESSION['csrf_token'], $token)) {
        http_response_code(403);
        exit('Invalid request.');
    }

    // Layer 2: Honeypot — hidden field that bots fill in
    if (!empty($_POST['website_url'])) {
        // Fake success — don't reveal the trap
        $success = true;
    }

    // Layer 3: Timing check — reject submissions under 3 seconds
    // Uses session timestamp (not client-provided) so it can't be spoofed
    if (!$success) {
        $elapsed = time() - ($_SESSION['form_loaded_at'] ?? time());
        if ($elapsed < 3) {
            $success = true; // Fake success to bot
        }
    }

    // Layer 4: Input validation
    if (!$success) {
        // Name — raw text, trim only, validate length
        $name = trim($_POST['name'] ?? '');
        if ($name === '' || mb_strlen($name) > 100) {
            $errors[] = 'Name is required (max 100 characters).';
        }

        // Email — format validation
        $email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
        if ($email === false || $email === null) {
            $errors[] = 'Enter a valid email address.';
            $email = $_POST['email'] ?? ''; // Keep for repopulation
        }

        // Message — raw text, trim, validate length
        $message = trim($_POST['message'] ?? '');
        if ($message === '' || mb_strlen($message) > 5000) {
            $errors[] = 'Message is required (max 5,000 characters).';
        }

        // Preserve form state for re-display on error
        $old = ['name' => $name, 'email' => (string)$email, 'message' => $message];

        // If valid, send the email
        if (empty($errors)) {
            mail(
                to: 'you@example.com',
                subject: 'Contact form submission',
                message: "Name: {$name}\nEmail: {$email}\n\n{$message}",
                additional_headers: "From: noreply@example.com\r\nReply-To: {$email}",
            );

            // Rotate CSRF token and reset timer after success
            $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
            unset($_SESSION['form_loaded_at']);
            $success = true;
        }
    }
} else {
    // Fresh page load — set the form load timestamp
    $_SESSION['form_loaded_at'] = time();
}
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Contact</title></head>
<body>

<?php if ($success): ?>
    <p><strong>Thanks!</strong> Your message has been sent.</p>
<?php else: ?>

    <?php if (!empty($errors)): ?>
        <ul class="errors">
            <?php foreach ($errors as $e): ?>
                <li><?= esc($e) ?></li>
            <?php endforeach; ?>
        </ul>
    <?php endif; ?>

    <form method="post" action="">
        <input type="hidden" name="csrf_token"
               value="<?= esc($_SESSION['csrf_token']) ?>">

        <!-- Honeypot — hidden from humans via positioning, not display:none -->
        <div style="position:absolute;left:-9999px" aria-hidden="true">
            <label for="website_url">Leave blank</label>
            <input type="text" name="website_url" id="website_url"
                   tabindex="-1" autocomplete="off">
        </div>

        <label for="name">Name</label>
        <input type="text" id="name" name="name" required
               maxlength="100" value="<?= esc($old['name']) ?>">

        <label for="email">Email</label>
        <input type="email" id="email" name="email" required
               value="<?= esc($old['email']) ?>">

        <label for="message">Message</label>
        <textarea id="message" name="message" required
                  maxlength="5000"><?= esc($old['message']) ?></textarea>

        <button type="submit">Send Message</button>
    </form>
<?php endif; ?>

</body>
</html>

This form includes four protection layers before any CAPTCHA is involved:

  1. CSRF token — prevents cross-site request forgery using a session-stored token validated with timing-safe hash_equals(). See our complete CSRF protection guide for token rotation and SameSite cookie strategies.
  2. Honeypot field — catches naive bots with zero user friction. Hidden with CSS positioning (not display:none, which some bots detect).
  3. Timing check — uses a session-stored timestamp (not a client-provided value) to reject submissions faster than 3 seconds.
  4. Input validation — raw text is trimmed and length-checked, email is validated with filter_input(), and all output is escaped with htmlspecialchars() at render time.

To add a CAPTCHA as a fifth layer, see our integration guides for Cloudflare Turnstile, reCAPTCHA, or hCaptcha.

Production Checklist

Check Why
All validation happens server-side Client-side JS is trivially bypassed
htmlspecialchars() uses ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' Prevents XSS in attribute values and handles bad encoding
Raw data stored; escaping happens at output time only Prevents double-encoding and preserves data integrity
Database queries use prepared statements (EMULATE_PREPARES => false) Real server-side parameter binding prevents SQL injection
CSRF token validated with hash_equals() Timing-safe comparison prevents token extraction
Select/radio/checkbox values validated against whitelist Users can submit arbitrary values outside your HTML options
Error messages don't reveal system internals Don't expose DB errors, file paths, or whether an email exists
Session ID regenerated after authentication Prevents session fixation attacks

The Verdict

Form validation isn't glamorous, but it's the foundation everything else sits on. CAPTCHA, honeypots, and rate limiting all fail if your validation lets XSS or SQL injection through the front door.

Validate on receipt. Store raw. Escape on output. Parameterise every query. The contact form above implements all four principles with a CSRF token, honeypot, and timing check layered on top — zero cost, zero user friction.

When validation and bot traps aren't enough, add a CAPTCHA. For the full strategy of defending PHP forms against spam, see our PHP form spam protection guide.