Skip to content

fix: Stop rejecting fresh reCAPTCHA tokens as stale - #144

Open
yahyafakhroji wants to merge 1 commit into
mainfrom
fix/recaptcha-drop-local-age-check
Open

yahyafakhroji wants to merge 1 commit into
mainfrom
fix/recaptcha-drop-local-age-check

Conversation

@yahyafakhroji

Copy link
Copy Markdown
Collaborator

Problem
Testing recovery on staging, both steps rejected a freshly minted reCAPTCHA token as stale even though Google had verified it with a score of 0.9:

signup_recaptcha_scored verdict=invalid reason=stale score=0.9 action=recovery
signup_recaptcha_scored verdict=invalid reason=stale score=0.9 action=recovery_code

recaptcha.server.ts applied its own two-minute age check after Google's success, keyed off challenge_ts. For reCAPTCHA v3 that timestamp is when the page's challenge was created, not when execute() minted the token, so anyone who took more than two minutes on the page was rejected. On the request step the route then issued a filler ticket and sent no mail, silently. #143's tolerateStale only covered the code step.

Solution
Delete the local age check and tolerateStale. Google already enforces expiry and single use, answering success=false with timeout-or-duplicate, which this file already handles as a rejection. Every other guard is unchanged: no-token, rejected, action mismatch, hostname mismatch, and the fail-open cases. Tests re-pinned so an old-but-valid token is accepted and timeout-or-duplicate is rejected.

Follow-up: two unrelated Cypress fixtures still stub the now-unused challenge_ts field. Harmless, not done here.

Refs #112

…kens

verifyRecaptcha accepted Google's success verdict and then rejected the
token as "stale" when challenge_ts was more than two minutes old. For v3,
challenge_ts is when the page's challenge was created, not when execute()
minted the token at submit time. Any form submitted more than about two
minutes after page load failed with a clean score.

Google already enforces expiry and single use, and reports both as
success=false with timeout-or-duplicate, which lands as reason "rejected".
The local check was redundant and anchored on the wrong timestamp.

Removes MAX_TOKEN_AGE_MS, the "stale" reason, and the tolerateStale option
from recaptchaRejects, so the recovery code step calls it plainly again.
The no-token, rejected, action-mismatch, hostname-mismatch and fail-open
paths are unchanged.

Staging, on both recovery steps:
  signup_recaptcha_scored verdict=invalid reason=stale score=0.9 action=recovery
  signup_recaptcha_scored verdict=invalid reason=stale score=0.9 action=recovery_code
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Summary

Job Status
E2E (fast, fake provider) ✅ success
Unit Tests (component) (0) ✅ success
Unit Tests (component) (1) ✅ success
Unit Tests (component) (2) ✅ success

View workflow run

@yahyafakhroji yahyafakhroji self-assigned this Sep 26, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant