PHP Form Validation: Security Best Practices That Actually Work
Stop getting hacked. Master input sanitization, SQL injection prevention, XSS protection, and CSRF tokens in PHP.
Your contact form is a door. And right now, it’s probably unlocked.
Every form on your PHP site is a potential entry point. SQL injection. Cross-site scripting. CSRF attacks. Spam bots hammering your endpoints. The threats are real and they’re constant.
The good news? PHP has solid tools for form security. The bad news? Most developers use them wrong. Or not at all.
This guide covers what actually works. Input sanitization that stops attacks. Validation that catches garbage data. Defense layers that protect your users. Let’s get into it.
Validation vs Sanitization: Know the Difference
These terms get thrown around interchangeably. They’re not the same thing.
Validation checks if data meets your rules. Is this email formatted correctly? Is this number within range? Is this field required? Validation answers yes or no. It doesn’t change the data.
Sanitization cleans the data. It removes or encodes dangerous characters. It transforms input into something safe to use.
You need both. Always.
Here’s the order that matters:
- Validate first - reject bad data early
- Sanitize before use - clean what you accept
- Escape on output - context-specific encoding
Skip any step and you’re vulnerable.
Input Sanitization in PHP
PHP gives you filter_var() with built-in sanitization filters. Use them.
// Sanitize email
$email = filter_var($_POST['email'], FILTER_SANITIZE_EMAIL);
// Sanitize URL
$url = filter_var($_POST['website'], FILTER_SANITIZE_URL);
// Sanitize integers
$age = filter_var($_POST['age'], FILTER_SANITIZE_NUMBER_INT);
// Strip tags from text
$name = filter_var($_POST['name'], FILTER_SANITIZE_FULL_SPECIAL_CHARS);
The FILTER_SANITIZE_STRING filter is deprecated as of PHP 8.1. Use FILTER_SANITIZE_FULL_SPECIAL_CHARS instead.
For text content, htmlspecialchars() is your friend:
$clean_input = htmlspecialchars($user_input, ENT_QUOTES | ENT_HTML5, 'UTF-8');
Always specify ENT_QUOTES to handle both single and double quotes. Always set UTF-8 encoding explicitly.
Validation After Sanitization
Clean data still needs validation:
$email = filter_var($_POST['email'], FILTER_SANITIZE_EMAIL);
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Invalid email address';
}
$age = filter_var($_POST['age'], FILTER_SANITIZE_NUMBER_INT);
if ($age < 18 || $age > 120) {
$errors[] = 'Age must be between 18 and 120';
}
Validate everything. Trust nothing from user input.
SQL Injection Prevention
SQL injection is the attack that refuses to die. It’s been around for decades. It still works because developers still write vulnerable code.
The vulnerable way:
// NEVER DO THIS
$query = "SELECT * FROM users WHERE email = '$email'";
$result = mysqli_query($conn, $query);
An attacker submits ' OR '1'='1 as the email. Your query becomes:
SELECT * FROM users WHERE email = '' OR '1'='1'
That returns every user in your database.
Prepared Statements Are Non-Negotiable
Use PDO with prepared statements. No exceptions.
$pdo = new PDO(
'mysql:host=localhost;dbname=myapp;charset=utf8mb4',
$username,
$password,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]
);
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
Notice PDO::ATTR_EMULATE_PREPARES => false. This is critical. By default, PDO emulates prepared statements in MySQL. Setting this to false uses real prepared statements at the database level.
Also set the charset in your DSN (charset=utf8mb4), not with a separate query. This prevents certain character-encoding attacks.
MySQLi Prepared Statements
If you’re using MySQLi instead of PDO:
$stmt = $conn->prepare('INSERT INTO contacts (name, email, message) VALUES (?, ?, ?)');
$stmt->bind_param('sss', $name, $email, $message);
$stmt->execute();
The 'sss' indicates three string parameters. Use 'i' for integers, 'd' for doubles.
What Prepared Statements Don’t Protect
Prepared statements only work for data values. They don’t protect dynamic table names, column names, or ORDER BY clauses.
For those, use allowlists:
$allowed_columns = ['name', 'email', 'created_at'];
$sort_column = in_array($_GET['sort'], $allowed_columns)
? $_GET['sort']
: 'created_at';
$query = "SELECT * FROM users ORDER BY {$sort_column} ASC";
Never interpolate user input for structural SQL elements.
XSS Protection
Cross-site scripting lets attackers inject malicious scripts into pages viewed by other users. Steal session cookies. Redirect to phishing sites. Deface pages.
The attack vector is stored user content. Comments. Profile bios. Form submissions. Anything that gets displayed back.
Output Encoding Is Everything
The rule is simple: encode data when outputting it. Not when storing it. On output.
// In your HTML template
<p>Welcome, <?php echo htmlspecialchars($username, ENT_QUOTES, 'UTF-8'); ?></p>
<div class="bio">
<?php echo htmlspecialchars($user_bio, ENT_QUOTES, 'UTF-8'); ?>
</div>
htmlspecialchars() converts:
<to<>to>&to&"to"'to'(with ENT_QUOTES)
This prevents script injection in HTML contexts.
Context Matters
Different output contexts need different encoding:
HTML attributes:
<input type="text" value="<?php echo htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); ?>">
JavaScript strings:
<script>
var username = <?php echo json_encode($username); ?>;
</script>
Use json_encode() for JavaScript contexts. It handles escaping properly.
URLs:
<a href="search.php?q=<?php echo urlencode($search_term); ?>">Search</a>
Use urlencode() for URL parameters.
Don’t Use strip_tags() for Security
strip_tags() is tempting but dangerous. It removes content between tags but doesn’t handle malformed HTML well. Attackers can craft inputs that bypass it.
Use it for display convenience, not security. Always combine with htmlspecialchars() for actual protection.
Content Security Policy
Add a CSP header as defense in depth:
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
This restricts where scripts can load from. Even if XSS sneaks through, the browser blocks the attack.
CSRF Tokens in PHP
Cross-Site Request Forgery tricks authenticated users into submitting unwanted requests. Attacker sites can trigger form submissions to your site. If the user is logged in, the request succeeds with their credentials.
How CSRF Works
User logs into your banking site. User visits a malicious site. That site has:
<form action="https://yourbank.com/transfer" method="POST" id="evil">
<input type="hidden" name="to" value="attacker-account">
<input type="hidden" name="amount" value="10000">
</form>
<script>document.getElementById('evil').submit();</script>
The form submits automatically. The browser attaches the user’s session cookie. Your server sees a valid authenticated request.
Token-Based Protection
Generate a random token per session. Include it in forms. Validate on submission.
// At session start
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// In your form
<form method="POST" action="/submit">
<input type="hidden" name="csrf_token"
value="<?php echo $_SESSION['csrf_token']; ?>">
<!-- other fields -->
<button type="submit">Submit</button>
</form>
Validate the token server-side:
if (empty($_POST['csrf_token']) ||
!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
die('Invalid CSRF token');
}
Use hash_equals() for comparison. Regular string comparison is vulnerable to timing attacks.
Token Best Practices
Generate tokens with random_bytes() or openssl_random_pseudo_bytes(). Never use predictable values like timestamps or user IDs.
Don’t put tokens in URLs. They leak through referrer headers and browser history.
Regenerate tokens after login to prevent session fixation attacks:
// After successful login
session_regenerate_id(true);
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
SameSite Cookies Help
Set your session cookie with SameSite attribute:
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => 'yourdomain.com',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict'
]);
session_start();
SameSite=Strict prevents the browser from sending cookies on cross-site requests. This blocks most CSRF attacks even without tokens.
But keep using tokens. SameSite has edge cases and older browser support varies.
Rate Limiting with PHP
Spam bots don’t care about validation. They’ll hit your form thousands of times an hour. Rate limiting stops the flood.
Basic Session-Based Limiting
Simple approach for low-traffic sites:
session_start();
$max_requests = 5;
$time_window = 60; // seconds
if (!isset($_SESSION['form_requests'])) {
$_SESSION['form_requests'] = [];
}
// Clean old requests
$_SESSION['form_requests'] = array_filter(
$_SESSION['form_requests'],
fn($timestamp) => $timestamp > time() - $time_window
);
if (count($_SESSION['form_requests']) >= $max_requests) {
http_response_code(429);
die('Too many requests. Please wait and try again.');
}
$_SESSION['form_requests'][] = time();
// Process form...
This limits each session to 5 submissions per minute.
Redis-Based Rate Limiting
For production systems, use Redis. It’s fast and handles concurrent requests properly.
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$ip = $_SERVER['REMOTE_ADDR'];
$key = "rate_limit:{$ip}";
$max_requests = 10;
$time_window = 60;
$current = $redis->get($key);
if ($current !== false && (int)$current >= $max_requests) {
http_response_code(429);
header('Retry-After: ' . $redis->ttl($key));
die('Rate limit exceeded');
}
$redis->multi();
$redis->incr($key);
$redis->expire($key, $time_window);
$redis->exec();
// Process form...
Redis handles the atomic increment and expiration. No race conditions.
Sliding Window Algorithm
The fixed window approach has an edge case. Submit 10 requests at second 59, then 10 more at second 61. You’ve made 20 requests in 2 seconds.
Sliding windows fix this:
$key = "sliding_rate:{$ip}";
$now = microtime(true);
$window = 60;
$max_requests = 10;
// Remove old entries
$redis->zRemRangeByScore($key, 0, $now - $window);
// Count recent requests
$count = $redis->zCard($key);
if ($count >= $max_requests) {
http_response_code(429);
die('Rate limit exceeded');
}
// Add current request
$redis->zAdd($key, $now, $now . ':' . uniqid());
$redis->expire($key, $window);
This uses a Redis sorted set to track exact timestamps. Smoother limiting, no burst exploits.
Integrating Spam Detection APIs
Validation stops malformed data. Rate limiting stops floods. But what about sophisticated spam?
Modern spammers use valid email formats. They submit coherent text. They pace their requests. They bypass CAPTCHAs with human-solving services.
This is where behavioral analysis comes in.
FormShield Integration
FormShield analyzes multiple signals to detect spam:
- IP reputation and VPN detection
- Disposable email identification
- AI-powered content analysis
- Submission timing patterns
- Honeypot field detection
function checkFormWithFormShield($data) {
$payload = [
'email' => $data['email'],
'name' => $data['name'],
'content' => $data['message'],
'ip' => $_SERVER['REMOTE_ADDR'],
'formId' => 'contact-form',
'metadata' => [
'formLoadedAt' => $data['form_loaded_at'],
'honeypotField' => $data['website'] ?? ''
]
];
$ch = curl_init('https://api.formshield.co/v1/check');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . FORMSHIELD_API_KEY
],
CURLOPT_RETURNTRANSFER => true
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
return $response;
}
// In your form handler
$spamCheck = checkFormWithFormShield($_POST);
if ($spamCheck['action'] === 'block') {
// Return fake success to avoid revealing detection
echo json_encode(['success' => true]);
exit;
}
// Process legitimate submission
The key insight: return fake success to spammers. Don’t let them know they’ve been caught. They’ll just adjust their tactics.
FormShield returns a score from 0-10 plus an action recommendation. You can customize thresholds or send borderline cases to manual review.
Putting It All Together
Here’s a complete form handler with all security layers:
<?php
session_start();
// CSRF validation
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($_POST['csrf_token']) ||
!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
die(json_encode(['error' => 'Invalid request']));
}
}
// Rate limiting
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$ip = $_SERVER['REMOTE_ADDR'];
$rate_key = "contact_form:{$ip}";
if ((int)$redis->get($rate_key) >= 5) {
http_response_code(429);
die(json_encode(['error' => 'Too many requests']));
}
// Sanitize input
$name = htmlspecialchars(trim($_POST['name'] ?? ''), ENT_QUOTES, 'UTF-8');
$email = filter_var($_POST['email'] ?? '', FILTER_SANITIZE_EMAIL);
$message = htmlspecialchars(trim($_POST['message'] ?? ''), ENT_QUOTES, 'UTF-8');
// Validate
$errors = [];
if (strlen($name) < 2 || strlen($name) > 100) {
$errors[] = 'Name must be 2-100 characters';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Invalid email address';
}
if (strlen($message) < 10 || strlen($message) > 5000) {
$errors[] = 'Message must be 10-5000 characters';
}
if (!empty($errors)) {
http_response_code(400);
die(json_encode(['errors' => $errors]));
}
// Spam check with FormShield
$spamCheck = checkFormWithFormShield([
'name' => $name,
'email' => $email,
'message' => $message,
'form_loaded_at' => $_POST['form_loaded_at'] ?? null,
'website' => $_POST['website'] ?? ''
]);
if ($spamCheck['action'] === 'block') {
// Fake success for spammers
echo json_encode(['success' => true, 'message' => 'Thanks for your message!']);
exit;
}
// Update rate limit
$redis->incr($rate_key);
$redis->expire($rate_key, 60);
// Store with prepared statement
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false
]);
$stmt = $pdo->prepare('
INSERT INTO contact_submissions (name, email, message, ip_address, spam_score, created_at)
VALUES (:name, :email, :message, :ip, :score, NOW())
');
$stmt->execute([
'name' => $name,
'email' => $email,
'message' => $message,
'ip' => $ip,
'score' => $spamCheck['score'] ?? 0
]);
echo json_encode(['success' => true, 'message' => 'Thanks for your message!']);
Common Mistakes to Avoid
Validating only on the client side. JavaScript validation is for UX, not security. Attackers bypass it trivially. Always validate server-side.
Trusting HTTP headers. $_SERVER['HTTP_X_FORWARDED_FOR'] can be spoofed. Don’t use it for rate limiting or security decisions without verification.
Storing raw user input. Store sanitized data. Never trust that future code will sanitize on output.
Using outdated functions. mysql_query() is gone. FILTER_SANITIZE_STRING is deprecated. Keep your PHP and functions current.
Forgetting about file uploads. File upload forms need their own security measures. Validate MIME types server-side. Store uploads outside the web root. Generate random filenames.
Skipping HTTPS. All the validation in the world means nothing if data travels unencrypted. Use TLS everywhere.
Security Is Layered
No single measure stops all attacks. Defense in depth means multiple layers, each catching what others miss.
- Validation catches malformed data
- Sanitization neutralizes dangerous characters
- Prepared statements prevent SQL injection
- Output encoding stops XSS
- CSRF tokens block forged requests
- Rate limiting stops floods
- Spam detection catches the sophisticated stuff
Each layer matters. Skip one and you’ve left a gap.
FormShield adds intelligent spam detection to your existing security stack. It analyzes what validation can’t see - behavioral patterns, reputation signals, content intent. Check out the free tier and see what’s hitting your forms.
Your forms are doors. Lock them properly.