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:
- Memorizing passwords or codes
- Solving puzzles (including image selection CAPTCHAs)
- Performing calculations (math CAPTCHAs)
- Transcribing distorted text
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:
- 46% failure rate among blind users (Towson University study by Lazar et al.)
- 65 seconds average completion time vs. 9.8 seconds for visual CAPTCHAs
- 29.5% of blind users disagree that audio alternatives are accessible to them (American Foundation for the Blind study, 237 participants)
- Screen readers conflict with audio playback — both speak simultaneously
- Deaf-blind users have no alternative at all
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
- 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?
- VoiceOver (macOS/iOS): Swipe through the form. Is the CAPTCHA iframe accessible? Does it trap focus?
- JAWS (Windows): Test in forms mode. Does the virtual cursor handle the CAPTCHA widget correctly?
Keyboard Testing
- Tab through the entire form without a mouse. Can you complete and submit it?
- Check for focus traps — CAPTCHA iframes sometimes capture keyboard focus and won't release it.
- Verify visible focus indicators on all interactive elements (WCAG 2.4.7).
Common Failures
- Focus trap in CAPTCHA iframe: User tabs into the widget and can't tab out. Fix: ensure the widget has proper
tabindexmanagement. - Missing ARIA labels: Screen reader announces "group" or nothing for the CAPTCHA. Fix: add
aria-label="Security verification"to the container. - Hidden timeout: Token expires while the user is completing the form with assistive technology (which takes longer). Fix: refresh the token on submit, not on load.
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:
- Netherlands: €90,000–€900,000
- Italy: Up to 5% of annual turnover
- Germany (BFSG): Fines plus injunctive relief
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.