Skip to content

Add invisible reCAPTCHA verification to the signup and forgot-password forms - #1633

Merged
DavidCockerill merged 7 commits into
stagefrom
david/627-recaptcha-client
Aug 19, 2026
Merged

Add invisible reCAPTCHA verification to the signup and forgot-password forms#1633
DavidCockerill merged 7 commits into
stagefrom
david/627-recaptcha-client

Conversation

@DavidCockerill

Copy link
Copy Markdown
Member

Adds an invisible reCAPTCHA Enterprise check to the two public auth forms — signup and forgot-password — so central-manager#677 can reject the scripted signups and reset-email bombing from the August 2026 campaign before it creates a user or sends an email. Nobody ever picks pictures: Google scores the interaction in the background, the form sends a single-use token as captchaToken, and the only visible change is Google's standard badge on those two pages. With VITE_RECAPTCHA_SITE_KEY unset the loader is tree-shaken out of the bundle entirely, so nothing changes in an environment that has no key.

For the human reviewer

  1. Forgot-password shows a CAPTCHA 403 twice — inline and in the global toast. Silencing the toast means routing every forgot-password error inline the way SignUp already does, which is [RUM] Half of sign-up submissions rejected 409 with no inline feedback — 7 of 8 affected sessions never created an account #1612's design rather than this PR's, so I left it. @dawsontoth you own that mechanism — if you'd rather forgot-password converted fully to inline, say so and I'll do it here. Both messages are accurate; only one is actionable.
  2. Any mint or script failure submits tokenless rather than blocking. central-manager fails open when it can't verify, so the client matches: a user on a network that blocks Google still gets through. The cost is that this PR stops zero abuse on its own — all of the enforcement lives server-side. Reversible in one file.
  3. The 403 is classified by matching the literal string "verification failed" across two repos, rather than a structured error code central-manager owns. Cheap to change now, sticky once both sides ship: if CM ever rewords that message the client silently drops back to the generic error and the tailored guidance disappears. A shared error code would be the root-cause fix.
  4. The error + ContactUs block is duplicated in both forms rather than extracted to a shared component. Two copies can drift; the mailto itself can't, since it lives in the existing ContactUs. Extraction is a ~6-line JSX component — worth it if you disagree with the call.
  5. Support is offered after the second consecutive rejection, not the first. A single failure is often a spent or stale token where retrying genuinely works, so offering support immediately would train people to email us over a self-healing blip. A low score, by contrast, cannot be retried away — the signals behind it don't change — which is why "please try again" alone was a dead end worth fixing.

Verification

  • pnpm test — 2298 pass, 11 skipped, 296 files; coverage for this change lives in four test files, three of them new (recaptchaScript.test.ts, isCaptchaVerificationError.test.ts, ForgotPassword.test.tsx) plus additions to the existing SignUp.test.tsx. tsc -b, oxlint, dprint clean. Rebased onto current stage (23 commits, including the typed-Axios 1.19 adaptation) and re-run against a fresh pnpm install --frozen-lockfile, not stale node_modules.
  • Proven end to end against a live, enforcing stage central-manager (that branch deployed, real stage key, local studio pointed at stage): signup 201 — real user created, verification email sent, so the 'signup' action, origin hostname and score all cleared a real Google assessment — and forgot-password 200, against 403 for a garbage token and 403 for no token at all. Raising the RECAPTCHA_MIN_SCORE Configuration row then made a normally-passing token 403 with this PR's "please try again" wording, which is the score branch and the client's message selection both firing for real.
  • Browser-verified UI states: badge renders on both forms; a blocked script yields a tokenless submit and the "could not run the verification check" variant rather than the retry one; two consecutive rejections surface the mailto:support@harperdb.io link. That last check caught a formatter-eaten space ("try again.Contact us"), now fixed and pinned by an assertion.
  • Both site keys are build-verified in isolation: the prod bundle carries only the prod key, the stage bundle only the stage key.
  • Not executed: e2e/Playwright (no reCAPTCHA-capable fixture). No organically low-scoring bot has been observed — the score branch was proven by raising the threshold above a good score, not by watching a bad one fail.

Review coverage

Authored by Claude (Fable 5). Cross-model pre-push review, two rounds against origin/stage. Round 1 (full, @ 7466e5c): codex gpt-5.6-sol ✓ (graded), gemini via agy (default model) ✓, Harper domain adjudication ✓ — Adjudicated-Severity: minor; cursor-grok ✗ (cursor-agent not installed on this machine). Round 2 (delta, @ f5e4b9f): same three legs, no new findings — converged. Independent coverage: true, both rounds. The comment nit was taken; the double-message finding is deliberately unresolved and is ledger entry 1 above. Three commits since the last reviewed SHA are trivial-tier and unreviewed by the legs: two comment rewordings and the prod site-key value (a config value flowing through the already-reviewed configured/unconfigured branches).

Refs HarperFast/central-manager#627 — cross-repo and based on stage, so closing keywords won't fire; #627 closes when both halves land.

⚠️ Deploy ordering matters. Stage central-manager is already enforcing, so stage.studio.harperfabric.com is currently 403ing both auth forms until this deploys — this PR is the fix. For production the reverse holds: this must deploy before RECAPTCHA_API_KEY lands in FABRIC_PRODUCTION, or prod signups 403 in the gap. Note the badge appears on prod as soon as this deploys, ahead of enforcement.

🤖 Generated with Claude Code

Human-Review-Need: 4 @ f53d9c6

DavidCockerill and others added 6 commits August 18, 2026 10:23
…assword

Mints a reCAPTCHA token (enterprise.js, score-based — no challenge is
ever shown; the standard badge renders bottom-right) as each form
submits and sends it as captchaToken in the POST /User and
POST /ForgotPassword bodies, so central-manager can reject scripted
signups and reset-email bombing before it does any work via the
Enterprise assessments API.

Tokens are single use and expire in ~2 minutes, so one is minted per
submit — a retry after any failure gets a fresh token by construction,
and a mint that hangs is bounded at 4s and degrades to a tokenless
submit rather than a dead form. A 403 from central-manager shows an
inline notice; when the script itself could not run (ad blocker), the
notice says that instead of asking the user to retry something that
cannot succeed, and the script load stays retryable rather than caching
the failure.

The site key comes from VITE_RECAPTCHA_SITE_KEY. Unset (all deployed
environments today) runs no reCAPTCHA at all — the loader is tree-shaken
out of the bundle — so nothing changes until a key is configured.

Refs HarperFast/central-manager#627

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ication

Review findings: a script element that fired neither load nor error left the
cached load promise pending forever, so every later submit ate the mint
timeout and could never recover without a reload — loads now time out at 8s,
remove their node (including the loaded-without-API stub case), and clear
the cache so a retry re-injects. The CAPTCHA 403 classifier also reads an
RFC 9457 problem body's title, so a server-side response reshape cannot
silently downgrade the ad-blocker guidance to the generic error path.

Refs HarperFast/central-manager#627

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A low score cannot be retried away — the signals behind it do not change —
so repeating "please try again" is a dead end. A second consecutive
rejection appends the existing ContactUs link (mailto:support@harperdb.io),
and any non-CAPTCHA failure resets the count.

Refs HarperFast/central-manager#627

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Public by design — it ships in the bundle. Pairs with RECAPTCHA_SITE_KEY
in FABRIC_PRODUCTION; the two must match or every assessment rejects.

Refs HarperFast/central-manager#627

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 57.54% 7198 / 12508
🔵 Statements 58.1% 7740 / 13321
🔵 Functions 50.19% 1815 / 3616
🔵 Branches 51.88% 5131 / 9889
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/config/constants.ts 100% 100% 100% 100%
src/features/auth/ForgotPassword.tsx 96% 90% 85.71% 96% 55
src/features/auth/SignUp.tsx 85.36% 76.47% 86.66% 87.5% 127-131
src/features/auth/isCaptchaVerificationError.ts 100% 100% 100% 100%
src/features/auth/hooks/useCaptchaChallenge.ts 100% 100% 100% 100%
src/features/auth/hooks/useForgotPassword.ts 83.33% 75% 100% 83.33% 24
src/features/auth/hooks/useSignUp.ts 83.33% 75% 100% 83.33% 22
src/lib/recaptcha/recaptchaScript.ts 95.45% 87.5% 100% 94.59% 72-73
Generated in workflow #1762 for commit 110db30 by the Vitest Coverage Report Action

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request integrates Google reCAPTCHA v3 into the public Sign Up and Forgot Password forms to protect against bot submissions. It introduces environment configuration variables, a custom useCaptchaChallenge hook, and script-loading utilities with timeout mechanisms, alongside comprehensive unit tests. The review feedback highlights a potential unhandled promise rejection in the token minting timeout logic when mintToken rejects after the timeout has already resolved, suggesting a direct .catch attachment to resolve it.

Comment thread src/lib/recaptcha/recaptchaScript.ts
@DavidCockerill
DavidCockerill marked this pull request as ready for review August 18, 2026 14:54
@DavidCockerill
DavidCockerill requested a review from a team as a code owner August 18, 2026 14:54
Comment thread src/features/auth/SignUp.tsx
@dawsontoth

Copy link
Copy Markdown
Contributor

ran locally, didn't notice any regression, seems ok -- captcha didn't show up for me, but i assume it assessed my login as low risk, and didn't trigger the display, which is a positive result.

@DavidCockerill

Copy link
Copy Markdown
Member Author

Thanks for running it — the no-regression signal on the surrounding flows is genuinely useful.

One thing worth flagging so you don't credit it with more than it did: locally you were almost certainly not exercising reCAPTCHA at all. VITE_RECAPTCHA_SITE_KEY is unset in .env.local.example, and with no key the loader is tree-shaken out of the bundle entirely — no script, no badge, no token on the wire. Local central-manager is also structurally exempt from enforcement, so even a token wouldn't have been checked. So "no regression" holds, but nothing captcha-shaped ran.

Also — and this is your own thread from the other comment — login isn't gated. Only signup and forgot-password are, so a sign-in would never have been assessed regardless.

On "captcha didn't show up": it never will. Score-based reCAPTCHA Enterprise is fully invisible — there's no checkbox and no image challenge to present, ever. The only visible artifact is Google's badge bottom-right, and that only appears once a site key is configured. (Scoring runs 0.0 bot → 1.0 human, so a good visitor scores high.)

If you want to actually drive it, point a local studio at stage, which is enforcing right now:

VITE_CENTRAL_MANAGER_API_URL=https://stage.studio.harperfabric.com
VITE_RECAPTCHA_SITE_KEY=6LckrIotAAAAAI4Kpk0kXiYtaAZKIBn2qhqabIfJ

The stage key allows localhost, so the badge appears and signup/forgot-password carry a real token that stage actually assesses. That's the rig used for the end-to-end verification in the description — signup 201 with a real token, and 403s for garbage or missing ones. — Claude (Fable 5)

@DavidCockerill

Copy link
Copy Markdown
Member Author

Correction to my previous comment: stage is no longer enforcing — David rolled the central-manager branch back off stage after I wrote that. So the .env.local rig I gave you would point at a stage that ignores tokens, and you'd see the badge appear but nothing actually assessed. Apologies for the bum steer.

There's no environment enforcing right now, so there isn't a self-serve way to drive the full loop until central-manager#677 is deployed somewhere again. Setting the site key locally still shows you the client half — badge renders, captchaToken on the wire in the request body — which is most of what's worth eyeballing on this PR anyway.

The end-to-end results in the description were captured while stage was enforcing (signup 201 with a real token, forgot-password 200, 403 for garbage and for no token, plus the score branch firing when the threshold was raised), so that evidence stands — it just isn't reproducible on demand at the moment. — Claude (Fable 5)

@dawsontoth

Copy link
Copy Markdown
Contributor

** i did a sign-up as my test, i mispoke

@DavidCockerill

Copy link
Copy Markdown
Member Author

Good to know — though it lands the same either way: with no VITE_RECAPTCHA_SITE_KEY set locally, signup wouldn't have exercised the CAPTCHA any more than sign-in would. Your regression signal on the signup flow itself still stands, which is the useful part. — Claude (Fable 5)

@dawsontoth

Copy link
Copy Markdown
Contributor

I borrowed the one from stage, set it in my local .env.local, to exercise it. VITE_RECAPTCHA_SITE_KEY

@DavidCockerill

Copy link
Copy Markdown
Member Author

Ah — then you exercised more than I gave you credit for, and I was wrong twice over. Sorry for the noise.

With the key set you'd have had the full client half: badge bottom-right, token minted at submit, captchaToken in the request body. The only variable left is what VITE_CENTRAL_MANAGER_API_URL pointed at — a local central-manager is structurally exempt, so it would have accepted the token without assessing it, whereas stage was genuinely assessing until it got rolled back earlier today. Either way the client path you cared about is the part that was under review here, and you drove it. — Claude (Fable 5)

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like a good security improvement.

🤖 Reviewed with Codex

Comment thread .env.local.example Outdated
Comment thread src/features/auth/ForgotPassword.tsx
Comment thread src/features/auth/isCaptchaVerificationError.ts Outdated
…CHA toast

Review findings from @kriszyp:

isCaptchaVerificationError returned the first STRING field rather than the
first field naming the cause, so { error: 'Forbidden', message:
'Verification failed' } read as non-CAPTCHA and silently dropped the
ad-blocker guidance and support path. Every candidate field is now checked
and RFC 9457's detail is included.

A CAPTCHA 403 also raised the global toast alongside the inline notice,
because useForgotPasswordMutation never opted out. It now does, and
non-CAPTCHA failures are re-raised through errorHandler so they keep the
toast they have always had. The test's QueryClient had no mutationCache, so
it could not have caught the duplication — it now wires the app's real
mutationErrorHandler, as SignUp.test.tsx already did.

Both new tests fail on the pre-fix code.

Refs HarperFast/central-manager#627

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@DavidCockerill
DavidCockerill added this pull request to the merge queue Aug 19, 2026
Merged via the queue into stage with commit eb48d4c Aug 19, 2026
2 checks passed
@DavidCockerill
DavidCockerill deleted the david/627-recaptcha-client branch August 19, 2026 15:17
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.

3 participants