Skip to main content

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

ScenarioEdge caseWhy tests breakApproach
reCAPTCHA v2 checkboxiframe cross-originCannot click in testTest keys always pass
reCAPTCHA v3 invisibleScore-based, no UITests hang waiting for widgetTest secret key + mock score endpoint
hCaptchaSimilar iframe issuesDOM selectors flakehCaptcha test sitekey
TurnstileCloudflare-managedNewer integration untestedTurnstile test keys
Server verify skippedClient-only checkFalse confidenceNegative POST without token
Bypass env in prodSKIP_CAPTCHA=true leakBots registerAssert env guard in deploy
Login vs signup onlyCAPTCHA on signup not loginCredential stuffing untestedMatrix per endpoint
Magic link sendCAPTCHA before emailUntested with passwordlessCombined spec
Rate limit + CAPTCHACAPTCHA after N failuresWrong order testedTrigger failures first
AccessibilityAudio challengeWCAG gapDocument manual; probe fallback exists
Mobile WebViewWidget not loadedAlways passes/failsAPI negative test
Multiple widgetsv3 + v2 on same pageToken confusionAssert correct action name
Token replayReuse captchaTokenShould failSubmit same token twice
Expired tokenDelay before submit401 from verify APIClock or short TTL
Enterprise reCAPTCHADomain allowlistWorks locally, fails CIAdd CI domain to console
Fail closed vs openVerify API downSecurity policyStub 503; assert behavior

Vendor test keys (use in CI)

ProviderTest key behaviorDocs
Google reCAPTCHA v2Always passesreCAPTCHA test keys
Google reCAPTCHA v3Returns fixed score with test secretSame FAQ
hCaptcha10000000-ffff-ffff-ffff-000000000001 sitekeyhCaptcha developer docs
Cloudflare Turnstile1x00000000000000000000AA always passesTurnstile 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:

  1. Bypass works only when NODE_ENV !== 'production' AND header present
  2. E2E includes one spec proving bypass fails in production config (unit test on env guard)
  3. 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:

SurfaceCAPTCHA typeBot riskTest priority
Signupv2 checkboxHighPR
Login after 5 failuresv3 invisibleMediumPR
Magic link sendTurnstileHighPR with magic link guide
Password resetv2MediumNightly
Contact formhCaptchaSpamNightly

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

  1. Test sitekey + secret in CI env—not production keys
  2. CI domain added to reCAPTCHA/Turnstile allowlist if using domain-restricted keys
  3. At least one negative API test without token per protected endpoint
  4. No third-party CAPTCHA solver services
  5. CAPTCHA specs run on signup AND login (if both gated)
  6. Bypass secret never in production deploy manifest
  7. Do not assert CAPTCHA iframe internal DOM—assert form submit outcome

Anti-patterns

Anti-patternWhy it failsBetter approach
2Captcha / solver APIsCost, ToS, flakesVendor test keys
Click reCAPTCHA iframe in CICross-origin blockTest keys
Client-only CAPTCHA checkAPI abuseServer verify probe
Skip all CAPTCHA in E2EBot signup in prodTest keys + negative API
Production keys in CIQuota + blockingDedicated test keys
waitForTimeout for v3Race on tokenexpect.poll token length
Assert "I'm not a robot" texti18n changesProbe 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.

  1. Arrange: No captchaToken in request body.
  2. Act: POST /api/signup with valid email/password only.
  3. 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).

External references

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.

Start free on TestChimp · Book a demo