How to Test CAPTCHA-Enabled Flows Without Flakiness
Short answer
CAPTCHA gates signup, login, and password reset—automation fails unless you use vendor test keys, server-side verify stubs, or scoped env bypass. Never rely on CAPTCHA-solving services in CI; assert server rejects missing tokens and accepts test tokens on protected endpoints.
Part of Testing Guides by auth and identity.
Who this is for
Teams shipping reCAPTCHA, hCaptcha, Cloudflare Turnstile, or custom bot detection on auth surfaces (signup, login, magic link send, contact forms) who need Playwright E2E that does not flake on invisible CAPTCHA scores or break when Google updates widget DOM.
Typical stacks: Next.js + reCAPTCHA v3 on login, Auth0 Attack Protection, Supabase + Turnstile, WordPress-style registration CAPTCHA.
Why testing CAPTCHA flows matters
CAPTCHA sits on the abuse boundary—getting it wrong has direct business impact:
- Revenue loss — signup CAPTCHA so aggressive that legitimate users abandon; A/B shows 12% drop but tests never exercised v3 score threshold.
- Security incidents — CAPTCHA only client-side; API accepts requests without
captchaToken; test env bypass leaked to production via misconfigured env var. - Support load — invisible v3 fails silently on Safari; users see generic "Something went wrong"; reset password CAPTCHA blocks accessibility users with no audio fallback tested.
- Compliance exposure — bot registration creates spam accounts storing PII; GDPR erasure cannot keep up because bot signups never tested.
E2E must assert server-side verification—client widget success alone is not proof.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| reCAPTCHA v2 checkbox | iframe cross-origin | Cannot click in test | Test keys always pass |
| reCAPTCHA v3 invisible | Score-based, no UI | Tests hang waiting for widget | Test secret key + mock score endpoint |
| hCaptcha | Similar iframe issues | DOM selectors flake | hCaptcha test sitekey |
| Turnstile | Cloudflare-managed | Newer integration untested | Turnstile test keys |
| Server verify skipped | Client-only check | False confidence | Negative POST without token |
| Bypass env in prod | SKIP_CAPTCHA=true leak | Bots register | Assert env guard in deploy |
| Login vs signup only | CAPTCHA on signup not login | Credential stuffing untested | Matrix per endpoint |
| Magic link send | CAPTCHA before email | Untested with passwordless | Combined spec |
| Rate limit + CAPTCHA | CAPTCHA after N failures | Wrong order tested | Trigger failures first |
| Accessibility | Audio challenge | WCAG gap | Document manual; probe fallback exists |
| Mobile WebView | Widget not loaded | Always passes/fails | API negative test |
| Multiple widgets | v3 + v2 on same page | Token confusion | Assert correct action name |
| Token replay | Reuse captchaToken | Should fail | Submit same token twice |
| Expired token | Delay before submit | 401 from verify API | Clock or short TTL |
| Enterprise reCAPTCHA | Domain allowlist | Works locally, fails CI | Add CI domain to console |
| Fail closed vs open | Verify API down | Security policy | Stub 503; assert behavior |
Vendor test keys (use in CI)
| Provider | Test key behavior | Docs |
|---|---|---|
| Google reCAPTCHA v2 | Always passes | reCAPTCHA test keys |
| Google reCAPTCHA v3 | Returns fixed score with test secret | Same FAQ |
| hCaptcha | 10000000-ffff-ffff-ffff-000000000001 sitekey | hCaptcha developer docs |
| Cloudflare Turnstile | 1x00000000000000000000AA always passes | Turnstile testing |
Site key (client) + secret key (server) pairs must both be test keys in CI. Mismatch causes verify failures.
reCAPTCHA v2 example
// .env.test
NEXT_PUBLIC_RECAPTCHA_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
RECAPTCHA_SECRET_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
test('signup with test reCAPTCHA succeeds', async ({ page, request }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill(`e2e-${Date.now()}@test.local`);
await page.getByLabel('Password').fill('SecurePw123!');
// Test key auto-passes—no iframe click needed
await page.getByRole('button', { name: 'Create account' }).click();
await page.waitForURL('/welcome');
expect((await request.get('/api/me')).status()).toBe(200);
});
reCAPTCHA v3 (invisible)
v3 runs on page load—wait for token before submit:
test('login with v3 token', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
// Wait until app sets hidden input or grecaptcha ready
await expect.poll(async () => {
return page.evaluate(() => {
const el = document.querySelector<HTMLInputElement>('input[name="captchaToken"]');
return el?.value?.length ?? 0;
});
}).toBeGreaterThan(10);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
});
Configure test secret so siteverify returns success with acceptable score in staging.
Server-side verification (required)
Every CAPTCHA E2E needs a negative control:
test('signup rejected without captcha token', async ({ request }) => {
const res = await request.post('/api/signup', {
data: {
email: `no-captcha-${Date.now()}@test.local`,
password: 'SecurePw123!',
// captchaToken intentionally omitted
},
});
expect(res.status()).toBe(400);
expect(await res.json()).toMatchObject({ code: 'CAPTCHA_REQUIRED' });
});
test('signup rejected with invalid captcha token', async ({ request }) => {
const res = await request.post('/api/signup', {
data: {
email: `bad-captcha-${Date.now()}@test.local`,
password: 'SecurePw123!',
captchaToken: 'invalid-token',
},
});
expect(res.status()).toBe(400);
});
Probe API directly—bypasses client widget entirely and catches "CAPTCHA theater."
Environment bypass pattern (safe)
Many teams use CAPTCHA_BYPASS_SECRET in staging for non-CAPTCHA specs. Rules:
- Bypass works only when
NODE_ENV !== 'production'AND header present - E2E includes one spec proving bypass fails in production config (unit test on env guard)
- Never skip CAPTCHA specs entirely—run at least signup + login with test keys per release
// Server — pseudo
if (process.env.CAPTCHA_BYPASS_SECRET && req.headers['x-e2e-bypass'] === process.env.CAPTCHA_BYPASS_SECRET) {
if (process.env.NODE_ENV === 'production') throw new Error('Bypass forbidden');
return next();
}
await verifyCaptcha(req.body.captchaToken);
Use /testchimp init to scaffold env-guard tests so agents do not remove bypass checks during refactors.
CAPTCHA trigger matrix
Document which surfaces require CAPTCHA:
| Surface | CAPTCHA type | Bot risk | Test priority |
|---|---|---|---|
| Signup | v2 checkbox | High | PR |
| Login after 5 failures | v3 invisible | Medium | PR |
| Magic link send | Turnstile | High | PR with magic link guide |
| Password reset | v2 | Medium | Nightly |
| Contact form | hCaptcha | Spam | Nightly |
TrueCoverage dimension captcha_trigger_point helps find untested surfaces.
Invisible CAPTCHA and UX failures
When v3 score is low, apps show v2 fallback or block silently:
test('low v3 score shows fallback challenge', async ({ page }) => {
// Stub siteverify in test to return score 0.1
await page.route('**/recaptcha/api/siteverify', route =>
route.fulfill({ json: { success: true, score: 0.1, action: 'login' } }),
);
await page.goto('/login');
// ... submit credentials
await expect(page.locator('.g-recaptcha')).toBeVisible(); // v2 fallback
});
Use route interception sparingly—prefer vendor test keys for happy path; stub for edge scores.
AIMock and failure injection
When verify API downtime should fail closed:
await page.route('**/recaptcha/api/siteverify', route =>
route.fulfill({ status: 503, body: 'Service Unavailable' }),
);
const res = await request.post('/api/signup', { data: validSignupPayload });
expect(res.status()).toBe(503); // or 400 — document policy
Align with security team on fail-open vs fail-closed before encoding in tests.
CI checklist
- Test sitekey + secret in CI env—not production keys
- CI domain added to reCAPTCHA/Turnstile allowlist if using domain-restricted keys
- At least one negative API test without token per protected endpoint
- No third-party CAPTCHA solver services
- CAPTCHA specs run on signup AND login (if both gated)
- Bypass secret never in production deploy manifest
- Do not assert CAPTCHA iframe internal DOM—assert form submit outcome
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| 2Captcha / solver APIs | Cost, ToS, flakes | Vendor test keys |
| Click reCAPTCHA iframe in CI | Cross-origin block | Test keys |
| Client-only CAPTCHA check | API abuse | Server verify probe |
| Skip all CAPTCHA in E2E | Bot signup in prod | Test keys + negative API |
| Production keys in CI | Quota + blocking | Dedicated test keys |
waitForTimeout for v3 | Race on token | expect.poll token length |
| Assert "I'm not a robot" text | i18n changes | Probe signup success |
Example scenario
Situation: Attacker POSTs /api/signup without completing client CAPTCHA widget.
Expected outcome: 400 CAPTCHA_REQUIRED—no user row created.
Why UI-only automation breaks: Playwright fills signup form with test key but no spec POSTs API without token—server regression undetected.
- Arrange: No captchaToken in request body.
- Act: POST /api/signup with valid email/password only.
- Assert: 400 CAPTCHA_REQUIRED; probe user count unchanged; rate limit counter not incremented for successful signup.
TestChimp workflow: Instrument signup_attempt with captcha_trigger_point and environment; compare prod signup CAPTCHA rate vs test.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Connect scenarios to your QA workflow
Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).
Related scenarios
- Magic links — CAPTCHA before send
- OAuth social login — bot registration via OAuth
- Firebase Authentication — App Check overlap
- MFA / 2FA — layered abuse controls
- Session timeout — brute force + lockout
- Transactional email — reset form CAPTCHA
External references
- reCAPTCHA FAQ — automated testing
- reCAPTCHA v3 verify
- hCaptcha documentation
- Cloudflare Turnstile testing
- OWASP CAPTCHA guidance
Frequently asked questions
What reCAPTCHA keys should I use in Playwright CI?
Google publishes universal test keys that always pass verification—site key 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI and matching secret. Set both client and server env vars in CI. Add your CI staging domain to console if using non-universal keys.
Can I click the reCAPTCHA checkbox in automated tests?
Avoid it—iframe cross-origin makes it brittle. Test keys auto-pass without interaction. Focus assertions on signup/login success and server verify.
How do I test that CAPTCHA is actually enforced server-side?
POST to signup/login API without captchaToken and expect 400. Repeat with invalid token. These probes catch client-only CAPTCHA that looks secure but allows direct API abuse.
reCAPTCHA v3 is invisible—how do I know the token is ready?
Poll for hidden input value length or grecaptcha.execute completion before clicking submit. Do not use fixed sleeps—invisible widgets race with form submission.
Is it OK to disable CAPTCHA in test environment?
Only with guarded bypass headers and never in production. Still run specs with vendor test keys to validate the full verify path. Include a unit test that bypass throws in production NODE_ENV.
Should I use CAPTCHA-solving services for E2E?
No—they violate vendor ToS, add cost, and flake. Use test keys, server stubs, and API negative tests. Solvers teach nothing about your integration.
We added Turnstile on magic link send—how do we extend coverage?
Combine patterns from this guide and the magic link guide; instrument captcha_trigger_point in TrueCoverage. If prod shows high magic_link CAPTCHA volume, add PR-level test keys on that form via /testchimp evolve.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.