CAPTCHA Accessibility: WCAG 2.2 Compliance for PHP Developers

CAPTCHAs are the most common accessibility barrier on the web. The W3C has called them problematic for over a decade. The WebAIM screen reader survey consistently ranks CAPTCHAs among the most frustrating elements for assistive technology users.

The European Accessibility Act (EAA) took effect June 28, 2025. WCAG 2.2 is the legal standard. If your PHP forms use CAPTCHAs that exclude disabled users, you're exposed to fines up to €900,000 in the Netherlands and 5% of annual turnover in Italy — regardless of where your company is based.

What WCAG 2.2 Requires

Three WCAG 2.2 success criteria directly affect CAPTCHAs:

SC 1.1.1 Non-text Content (Level A)

Any CAPTCHA image or audio must have a text alternative that describes its purpose — not the answer, but what it is. For example: alt="Security challenge — type the characters shown". CAPTCHAs get a specific exception from providing full text alternatives (since that would defeat the purpose), but they must still identify themselves and provide alternatives using different sensory modes.

SC 2.2.1 Timing Adjustable (Level A)

If your CAPTCHA has a time limit (many do — reCAPTCHA tokens expire in 2 minutes, hCaptcha in ~120 seconds), users must be able to extend it. Hidden timeouts that silently invalidate the form violate this criterion.

SC 3.3.8 Accessible Authentication (Level AA)

This is the big one, added in WCAG 2.2 (October 2023). It prohibits cognitive function tests as the sole authentication or verification method — unless alternatives are provided. Cognitive function tests include:

What this means: If your login or registration form uses a traditional CAPTCHA as the only verification method, you're violating WCAG 2.2 AA. You need either a non-cognitive alternative or a different approach entirely.

How Each CAPTCHA Type Scores on Accessibility

CAPTCHA Type Visual Motor Cognitive Hearing WCAG 3.3.8
Image selection (hCaptcha, reCAPTCHA v2) Fails Difficult Moderate OK Fails without alternative
Text distortion (Gregwar/Captcha) Fails OK Moderate OK Fails without alternative
Audio challenge OK OK Difficult Fails Fails as sole method
Math CAPTCHA Depends OK Fails (dyscalculia) OK Fails (cognitive test)
Invisible scoring (reCAPTCHA v3) OK* OK* OK* OK OK*
Invisible challenge (Turnstile) OK OK OK OK Passes
Proof-of-work (ALTCHA) OK OK OK OK Passes
Honeypot field OK OK OK OK Passes

* reCAPTCHA v3 only returns a score. It never shows challenges itself — but developers must build their own fallback for low scores, and those fallbacks may not be accessible.

The Audio CAPTCHA Myth

Audio CAPTCHAs are the traditional "accessible alternative." The research shows they don't work well:

Adding an audio option technically satisfies WCAG 1.1.1's "different sensory modes" requirement. But it doesn't satisfy the spirit of accessibility, and it doesn't protect you from SC 3.3.8 if the audio is a cognitive function test.

Accessible Approaches That Actually Work

1. Invisible Verification (Best Option)

If users never see a challenge, there's nothing to be inaccessible. Cloudflare Turnstile and proof-of-work solutions like ALTCHA verify users without any visible interaction.

<?php
// Accessible bot protection: Turnstile (invisible to all users)
// Frontend: just add the widget div — no user interaction needed
// Server-side: standard token verification
function verifyTurnstile(string $token, string $secret): bool
{
    $ch = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query([
            'secret'   => $secret,
            'response' => $token,
            'remoteip' => $_SERVER['REMOTE_ADDR'] ?? '',
        ]),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 5,
    ]);

    $response = curl_exec($ch);
    curl_close($ch);

    $data = json_decode($response ?: '{}', true);
    return $data['success'] ?? false;
}

This passes every WCAG 2.2 criterion. No visual element, no cognitive test, no timing issue. One caveat: Turnstile tokens expire after a few minutes. For long forms used with assistive technology, refresh the token on submit using the Turnstile JavaScript API's turnstile.reset() method. See the full Turnstile integration guide for production setup.

2. Honeypot Fields (Zero Friction)

A hidden form field that humans skip but bots fill in. Fully accessible, zero user interaction, no third-party dependency.

<?php
// Accessible honeypot implementation
// The hidden field must be hidden via CSS, not type="hidden" (bots skip those)

// In your HTML form:
// <div style="position:absolute;left:-9999px" aria-hidden="true">
//   <label for="website">Leave blank</label>
//   <input type="text" name="website" id="website" tabindex="-1" autocomplete="off">
// </div>

// Server-side check
if (!empty($_POST['website'])) {
    // Bot detected — reject silently
    http_response_code(200); // Don't reveal the trap
    exit;
}

Key accessibility details: Use aria-hidden="true" so screen readers skip the field. Use tabindex="-1" so keyboard users can't tab into it. Don't use display: none — some bots detect that. Read our honeypot guide for advanced techniques including time-based traps.

3. Layered Defense (Strongest Protection)

Combine accessible techniques for stronger protection without any user-facing challenge:

<?php
// Layered accessible bot protection — PHP 8.0+

define('HMAC_SECRET', getenv('FORM_HMAC_SECRET')); // set in your environment

// Generate a signed timestamp field when rendering the form
function generateTimingField(): string
{
    $time = (string) time();
    $sig  = hash_hmac('sha256', $time, HMAC_SECRET);
    return $time . '.' . $sig;  // embed as a hidden field value
}

// Verify the signed timestamp and return the elapsed seconds (or -1 on failure)
function verifyTimingField(string $field): int
{
    $parts = explode('.', $field, 2);
    if (count($parts) !== 2) {
        return -1;
    }
    [$time, $sig] = $parts;
    if (!hash_equals(hash_hmac('sha256', $time, HMAC_SECRET), $sig)) {
        return -1; // tampered
    }
    return time() - (int) $time;
}

function checkBotSignals(array $post): array
{
    $flags = [];

    // Layer 1: Honeypot
    if (!empty($post['website'])) {
        $flags[] = 'honeypot_triggered';
    }

    // Layer 2: HMAC-signed timing (works across multiple tabs)
    $elapsed = verifyTimingField($post['_timing'] ?? '');
    if ($elapsed < 0) {
        $flags[] = 'timing_tampered';
    } elseif ($elapsed < 3) {
        $flags[] = 'too_fast';
    }

    // Layer 3: JavaScript token
    // Generate a unique token per form load and embed it via JS.
    // Bots without a JS engine will not have this value.
    //
    // Example JS (inline in your form template):
    //   <script>
    //     document.addEventListener('DOMContentLoaded', function () {
    //       document.getElementById('js_token').value =
    //         document.querySelector('[name="_timing"]').value.split('.')[1];
    //     });
    //   </script>
    //
    // Then verify server-side that js_token matches the HMAC portion of _timing.
    $timingParts = explode('.', $post['_timing'] ?? '', 2);
    $expectedJs  = $timingParts[1] ?? '';
    if (empty($post['js_token']) || $post['js_token'] !== $expectedJs) {
        $flags[] = 'no_javascript';
    }

    return $flags;
}

// Usage — no session needed
// In your form HTML:
//   <input type="hidden" name="_timing" value="<?= htmlspecialchars(generateTimingField()) ?>">
//   <input type="hidden" name="js_token" id="js_token" value="">

$flags = checkBotSignals($_POST);

if (count($flags) >= 2) {
    // High confidence: bot. Reject.
    $errors[] = 'Submission blocked. Please try again.';
} elseif (count($flags) === 1) {
    // Ambiguous: escalate to Turnstile verification
    $valid = verifyTurnstile($_POST['cf-turnstile-response'] ?? '', $secret);
    if (!$valid) {
        $errors[] = 'Verification failed.';
    }
}

This approach is fully WCAG 2.2 compliant — no visible challenge unless the escalation path triggers Turnstile, which is itself invisible to most users.

Testing Your CAPTCHA's Accessibility

Automated tools catch about 30% of accessibility issues. For CAPTCHAs, manual testing is essential.

Screen Reader Testing

  1. NVDA (Windows, free): Navigate your form with Tab. Can the user reach the submit button? Does the screen reader announce the CAPTCHA widget's purpose?
  2. VoiceOver (macOS/iOS): Swipe through the form. Is the CAPTCHA iframe accessible? Does it trap focus?
  3. JAWS (Windows): Test in forms mode. Does the virtual cursor handle the CAPTCHA widget correctly?

Keyboard Testing

  1. Tab through the entire form without a mouse. Can you complete and submit it?
  2. Check for focus traps — CAPTCHA iframes sometimes capture keyboard focus and won't release it.
  3. Verify visible focus indicators on all interactive elements (WCAG 2.4.7).

Common Failures

The Legal Landscape (2026)

European Accessibility Act (EAA)

Enforceable since June 28, 2025. Applies to any business selling to EU consumers — not just EU-based companies. Penalties vary by member state:

The EAA's Annex I explicitly requires alternatives to CAPTCHA for verification. If your site serves EU customers and uses a visual-only CAPTCHA with no accessible path, you're non-compliant today.

ADA (United States)

Over 8,800 ADA web accessibility lawsuits were filed in 2024, a 7% increase. California alone saw 3,252 filings. Typical settlements run $15,000–$25,000, but litigation costs exceed $60,000. CAPTCHAs that block screen reader users are a common complaint in these cases.

Section 508 (US Federal)

Federal agency websites must comply. WCAG 2.2 AA is the recommended standard. If you build PHP applications for government clients, inaccessible CAPTCHAs are a contract risk.

Verdict: The Accessible CAPTCHA Decision Tree

Can you use no CAPTCHA at all? If your form gets minimal spam, a honeypot alone may be enough. Zero user friction, fully accessible, no compliance risk.

Need bot protection? Use Cloudflare Turnstile. Invisible, no cognitive test, no accessibility barrier. Combine with a honeypot for layered defense.

Need strict GDPR compliance? ALTCHA (self-hosted proof-of-work). No data leaves your server, no cookies, no consent banner needed.

Must use a visible CAPTCHA? Provide an accessible alternative. If you use hCaptcha or reCAPTCHA v2 on your login form, also offer email-based verification or a passkey option. Document the alternative path in your accessibility statement.

The best CAPTCHA for accessibility is the one users never see. Every visible challenge excludes someone. Build your bot protection around invisible verification and save visible CAPTCHAs for escalation only.