Skip to main content
← Back to articles

Securing PHP Forms in 2026: Beyond CSRF

6 min read

The Problem

In 2026, a simple contact form remains one of the most exploited attack surfaces on the web. Bots are more sophisticated, brute-force attacks more frequent, and a CSRF token alone is no longer sufficient. On this site, I implemented a defense-in-depth strategy with four complementary layers.

Layer 1: CSRF Token with Rotation

The classic approach, but correctly implemented. A unique token per session, regenerated after each successful submission, with server-side validation via hash_equals() to prevent timing attacks:

<?php
final class CsrfGuard
{
    public function generateToken(): string
    {
        $token = bin2hex(random_bytes(32));
        $_SESSION['csrf_token'] = $token;
        $_SESSION['csrf_time'] = time();
        return $token;
    }

    public function validateToken(string $submitted): bool
    {
        $stored = $_SESSION['csrf_token'] ?? '';
        if (!hash_equals($stored, $submitted)) {
            return false;
        }
        // Invalidate after use (one-time token)
        unset($_SESSION['csrf_token']);
        return true;
    }
}

Using hash_equals() is essential: a standard comparison with === can leak timing information exploitable by an attacker.

Layer 2: Invisible Honeypot

A CSS-hidden field (not display:none which some bots detect) traps robots that automatically fill all form fields:

// In the template
<div style="position:absolute;left:-9999px" aria-hidden="true">
    <input type="text" name="website" tabindex="-1" autocomplete="off">
</div>

// In the controller
if (!empty($_POST['website'])) {
    // It's a bot — return a fake success
    http_response_code(200);
    exit;
}

The fake success response is intentional: returning a 4xx error would inform the bot it was detected, allowing it to adapt its strategy.

Layer 3: IP-Based Rate Limiting

Without a database, I use the filesystem to limit submissions per IP. Simple, effective, and dependency-free:

final class RateLimiter
{
    public function __construct(
        private readonly string $storageDir,
        private readonly int $maxAttempts = 3,
        private readonly int $windowSeconds = 3600,
    ) {}

    public function isAllowed(string $ip): bool
    {
        $file = $this->storageDir . '/' . md5($ip) . '.json';
        if (!file_exists($file)) {
            return true;
        }

        $data = json_decode(file_get_contents($file), true);
        $recent = array_filter(
            $data['attempts'] ?? [],
            fn(int $t) => $t > time() - $this->windowSeconds
        );

        return count($recent) < $this->maxAttempts;
    }
}

In production, a CRON job to clean expired files completes the setup. For high-traffic sites, Redis or Memcached would be preferable.

Layer 4: Time Check (Fast-Bot Detection)

A human takes at least 3 to 5 seconds to fill out a form. A bot does it in milliseconds. We record the timestamp when the form is rendered and verify the duration server-side:

// When generating the form
$_SESSION['form_rendered_at'] = time();

// On submission
$elapsed = time() - ($_SESSION['form_rendered_at'] ?? time());
if ($elapsed < 3) {
    // Submission too fast — probably a bot
    Logger::warning('Bot detected: form submitted in {s}s', ['s' => $elapsed]);
    return $this->fakeSuccess();
}

Orchestration: The Validation Middleware

These four layers are orchestrated in a single middleware that executes them sequentially. If one fails, the request is rejected (or a fake success is returned for bots):

final class FormSecurityMiddleware
{
    public function process(Request $request): ValidationResult
    {
        // 1. Rate limit
        if (!$this->rateLimiter->isAllowed($request->ip())) {
            return ValidationResult::blocked('rate_limit');
        }
        // 2. Honeypot
        if (!empty($request->post('website'))) {
            return ValidationResult::silent();
        }
        // 3. Time check
        if ($this->isSubmittedTooFast()) {
            return ValidationResult::silent();
        }
        // 4. CSRF
        if (!$this->csrf->validateToken($request->post('_token', ''))) {
            return ValidationResult::blocked('csrf');
        }

        return ValidationResult::passed();
    }
}

Conclusion

None of these layers is individually foolproof. CSRF alone doesn't block bots. A honeypot alone doesn't withstand targeted bots. But combined, they form a defense-in-depth that makes exploitation prohibitively expensive. This is the fundamental principle of security: raise the cost of attack until it's no longer profitable.

For a portfolio site without a database, this approach offers an excellent security-to-complexity ratio. For a critical application, add a WAF, progressive CAPTCHA validation, and centralized logging in a SIEM.

Keep this portfolio close

On iPhone or iPad, open this site in Safari, tap Share, then “Add to Home Screen”. On Android or desktop, use “Install” in your browser menu when available.

Previously visited public pages remain available offline when a saved copy exists. The contact form needs a connection. No message is sent in the background.