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:
- 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. - Honeypot field — catches naive bots with zero user friction. Hidden with CSS positioning (not
display:none, which some bots detect). - Timing check — uses a session-stored timestamp (not a client-provided value) to reject submissions faster than 3 seconds.
- Input validation — raw text is trimmed and length-checked, email is validated with
filter_input(), and all output is escaped withhtmlspecialchars()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.