From fffdbc93293717a4dd2dbff6e7adaf0f262c5763 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:32:27 -0400 Subject: [PATCH] Add Cloudflare Turnstile captcha on email signup (#326) --- src/client/features/auth/TurnstileWidget.tsx | 127 ++++++++++++++++++ src/env.d.ts | 6 + src/lib/auth.ts | 27 +++- src/routes/_auth.sign-up.tsx | 130 ++++++++++++++----- vite.config.ts | 1 + 5 files changed, 254 insertions(+), 37 deletions(-) create mode 100644 src/client/features/auth/TurnstileWidget.tsx diff --git a/src/client/features/auth/TurnstileWidget.tsx b/src/client/features/auth/TurnstileWidget.tsx new file mode 100644 index 0000000..fd3ca3b --- /dev/null +++ b/src/client/features/auth/TurnstileWidget.tsx @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +const TURNSTILE_SCRIPT_SRC = + "https://challenges.cloudflare.com/turnstile/v0/api.js"; + +// Public Turnstile site key, inlined at build time (see vite.config envPrefix). +// The widget only renders when it's set, so unconfigured / self-hosted builds +// are unaffected. The matching TURNSTILE_SECRET_KEY lives server-side only. +export const TURNSTILE_SITE_KEY = import.meta.env.TURNSTILE_SITE_KEY?.trim(); + +type TurnstileApi = { + render: ( + element: HTMLElement, + options: { + sitekey: string; + callback: (token: string) => void; + "expired-callback"?: () => void; + "error-callback"?: () => void; + }, + ) => string; + reset: (widgetId: string) => void; + remove: (widgetId: string) => void; +}; + +declare global { + interface Window { + turnstile?: TurnstileApi; + } +} + +// Captcha state for a form: the token in a ref (read at submit time, so the +// form's submit closure never sees a stale value), a boolean mirror to drive +// the submit button, and a reset (tokens are single-use — re-challenge after a +// failed submit). Wire `onToken`/`resetNonce` into . +export function useTurnstileCaptcha() { + const tokenRef = useRef(null); + const [hasToken, setHasToken] = useState(false); + const [resetNonce, setResetNonce] = useState(0); + + const onToken = useCallback((token: string | null) => { + tokenRef.current = token; + setHasToken(Boolean(token)); + }, []); + + const reset = useCallback(() => { + tokenRef.current = null; + setHasToken(false); + setResetNonce((nonce) => nonce + 1); + }, []); + + return { tokenRef, hasToken, resetNonce, onToken, reset }; +} + +// Renders the Cloudflare Turnstile challenge and reports its token. `onToken` +// fires with the token when solved and with null when it expires/errors. +// Bump `resetNonce` to re-challenge (tokens are single-use, so reset after a +// failed submit). +export function TurnstileWidget({ + onToken, + resetNonce, +}: { + onToken: (token: string | null) => void; + resetNonce: number; +}) { + const containerRef = useRef(null); + const widgetIdRef = useRef(null); + // Keep the latest callback in a ref so mounting the widget stays a one-time + // effect (a fresh onToken each render must not tear down and re-render it). + const onTokenRef = useRef(onToken); + onTokenRef.current = onToken; + + useEffect(() => { + if (!TURNSTILE_SITE_KEY) return; + let cancelled = false; + + const renderWidget = () => { + if ( + cancelled || + widgetIdRef.current !== null || + !containerRef.current || + !window.turnstile + ) { + return; + } + widgetIdRef.current = window.turnstile.render(containerRef.current, { + sitekey: TURNSTILE_SITE_KEY, + callback: (token) => onTokenRef.current(token), + "expired-callback": () => onTokenRef.current(null), + "error-callback": () => onTokenRef.current(null), + }); + }; + + if (window.turnstile) { + renderWidget(); + } else { + const existing = document.querySelector( + `script[src="${TURNSTILE_SCRIPT_SRC}"]`, + ); + const script = existing ?? document.createElement("script"); + script.addEventListener("load", renderWidget); + if (!existing) { + script.src = TURNSTILE_SCRIPT_SRC; + script.async = true; + document.head.appendChild(script); + } + } + + return () => { + cancelled = true; + if (widgetIdRef.current !== null && window.turnstile) { + window.turnstile.remove(widgetIdRef.current); + } + widgetIdRef.current = null; + }; + }, []); + + useEffect(() => { + if (resetNonce === 0) return; + if (widgetIdRef.current !== null && window.turnstile) { + window.turnstile.reset(widgetIdRef.current); + onTokenRef.current(null); + } + }, [resetNonce]); + + if (!TURNSTILE_SITE_KEY) return null; + return
; +} diff --git a/src/env.d.ts b/src/env.d.ts index 65a0059..ba0d7ba 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -30,6 +30,11 @@ declare namespace Cloudflare { AUTUMN_SECRET_KEY?: string; AUTUMN_WEBHOOK_SECRET?: string; + // Cloudflare Turnstile — signup captcha (hosted only). Secret verifies + // tokens server-side; site key is public and inlined into the client build. + TURNSTILE_SECRET_KEY?: string; + TURNSTILE_SITE_KEY?: string; + // DataForSEO API Basic auth value (base64 of login:password) DATAFORSEO_API_KEY: string; @@ -46,6 +51,7 @@ interface ImportMetaEnv { readonly BYPASS_EMAIL_VERIFICATION?: string; readonly POSTHOG_PUBLIC_KEY?: string; readonly POSTHOG_HOST?: string; + readonly TURNSTILE_SITE_KEY?: string; readonly VITE_E2E_DOMAIN_FIXTURES?: string; readonly VITE_E2E_KEYWORD_FIXTURES?: string; } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index a1060a0..5e1ee3a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,6 +1,7 @@ import { env } from "cloudflare:workers"; import { betterAuth } from "better-auth"; import { APIError } from "better-auth/api"; +import { captcha } from "better-auth/plugins"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { tanstackStartCookies } from "better-auth/tanstack-start"; import { isDisposableEmailDomain } from "@/server/auth/disposable-email"; @@ -40,6 +41,18 @@ function createAuth() { const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true"; const baseAuthConfig = createBaseAuthConfig(); + // Turnstile captcha on signup — hosted only, and only when BOTH keys are set. + // Requiring the site key too (not just the secret) keeps the server in + // lockstep with the client widget, which renders only when the site key is + // present: a secret-only deploy would otherwise fail closed and reject every + // signup (client sends no token). Left off entirely when unconfigured so + // local/self-hosted builds are unaffected. Relies on the same + // build-env == runtime-env contract as AUTH_MODE. + const turnstileSecretKey = + isHostedAuthMode(env.AUTH_MODE) && env.TURNSTILE_SITE_KEY?.trim() + ? env.TURNSTILE_SECRET_KEY?.trim() + : undefined; + const database = getDatabaseProvider() === "postgres" ? drizzleAdapter(pgDb, { @@ -82,7 +95,19 @@ function createAuth() { socialProviders: getSocialProviders(), trustedOrigins: getTrustedOrigins(baseUrl), database, - plugins: [...baseAuthConfig.plugins, tanstackStartCookies()], + plugins: [ + ...baseAuthConfig.plugins, + ...(turnstileSecretKey + ? [ + captcha({ + provider: "cloudflare-turnstile", + secretKey: turnstileSecretKey, + endpoints: ["/sign-up/email"], + }), + ] + : []), + tanstackStartCookies(), + ], databaseHooks: { user: { create: { diff --git a/src/routes/_auth.sign-up.tsx b/src/routes/_auth.sign-up.tsx index c3bf066..00cf51e 100644 --- a/src/routes/_auth.sign-up.tsx +++ b/src/routes/_auth.sign-up.tsx @@ -7,6 +7,11 @@ import { authRedirectSearchSchema, useAuthPageState, } from "@/client/features/auth/AuthPage"; +import { + TURNSTILE_SITE_KEY, + TurnstileWidget, + useTurnstileCaptcha, +} from "@/client/features/auth/TurnstileWidget"; import { getFieldError, getFormError } from "@/client/lib/forms"; import { captureClientEvent } from "@/client/lib/posthog"; import { authClient } from "@/lib/auth-client"; @@ -49,8 +54,11 @@ function SignUpPage() { const { redirectTo, isHostedMode } = useAuthPageState(search.redirect); const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo; const [showEmailForm, setShowEmailForm] = useState(false); - const [isStartingGoogle, setIsStartingGoogle] = useState(false); - const [socialError, setSocialError] = useState(null); + const google = useGoogleSignUp({ redirectTo, postSignupRedirect }); + + // Turnstile is active only in hosted mode with a configured site key. + const isTurnstileEnabled = isHostedMode && Boolean(TURNSTILE_SITE_KEY); + const captcha = useTurnstileCaptcha(); const form = useForm({ defaultValues: { @@ -63,6 +71,16 @@ function SignUpPage() { onSubmit: signUpSchema, }, onSubmit: async ({ formApi, value }) => { + const captchaToken = captcha.tokenRef.current; + if (isTurnstileEnabled && !captchaToken) { + formApi.setErrorMap({ + onSubmit: { + form: "Please complete the captcha to continue.", + fields: {}, + }, + }); + return; + } try { const email = value.email.trim(); captureClientEvent("auth:sign_up_submit", { @@ -89,9 +107,18 @@ function SignUpPage() { email, password: value.password, callbackURL: verificationCallbackURL.toString(), + ...(isTurnstileEnabled && captchaToken + ? { + fetchOptions: { + headers: { "x-captcha-response": captchaToken }, + }, + } + : {}), }); if (result.error) { + // Turnstile tokens are single-use; re-challenge so a retry can succeed. + if (isTurnstileEnabled) captcha.reset(); formApi.setErrorMap({ onSubmit: { form: result.error.message || "Unable to create account.", @@ -110,6 +137,7 @@ function SignUpPage() { replace: true, }); } catch { + if (isTurnstileEnabled) captcha.reset(); formApi.setErrorMap({ onSubmit: { form: "Unable to create account right now. Please try again.", @@ -120,33 +148,6 @@ function SignUpPage() { }, }); - async function handleContinueWithGoogle() { - setSocialError(null); - setIsStartingGoogle(true); - - try { - captureClientEvent("auth:sign_up_google_start", { - redirect_to: redirectTo, - }); - const result = await authClient.signIn.social({ - provider: "google", - callbackURL: redirectTo, - newUserCallbackURL: postSignupRedirect, - requestSignUp: true, - }); - - if (result.error) { - setSocialError( - result.error.message || "Google sign up is not available right now.", - ); - setIsStartingGoogle(false); - } - } catch { - setSocialError("Google sign up is not available right now."); - setIsStartingGoogle(false); - } - } - return ( { setShowEmailForm(false); - setSocialError(null); + google.clearError(); }} > Back to signup @@ -207,17 +208,17 @@ function SignUpPage() { { - void handleContinueWithGoogle(); + void google.start(); }} onContinueWithEmail={() => { setShowEmailForm(true); - setSocialError(null); + google.clearError(); }} /> - {socialError ? ( -

{socialError}

+ {google.error ? ( +

{google.error}

) : null} ) : ( @@ -327,6 +328,13 @@ function SignUpPage() { }} + {isTurnstileEnabled ? ( + + ) : null} + ({ submitError: state.errorMap.onSubmit, @@ -342,7 +350,11 @@ function SignUpPage() { ) : null} @@ -355,3 +367,49 @@ function SignUpPage() {
); } + +// Google sign-up: kicks off the social OAuth redirect and surfaces its error. +function useGoogleSignUp({ + redirectTo, + postSignupRedirect, +}: { + redirectTo: string; + postSignupRedirect: string; +}) { + const [isStarting, setIsStarting] = useState(false); + const [error, setError] = useState(null); + + const start = async () => { + setError(null); + setIsStarting(true); + + try { + captureClientEvent("auth:sign_up_google_start", { + redirect_to: redirectTo, + }); + const result = await authClient.signIn.social({ + provider: "google", + callbackURL: redirectTo, + newUserCallbackURL: postSignupRedirect, + requestSignUp: true, + }); + + if (result.error) { + setError( + result.error.message || "Google sign up is not available right now.", + ); + setIsStarting(false); + } + } catch { + setError("Google sign up is not available right now."); + setIsStarting(false); + } + }; + + return { + isStarting, + error, + start, + clearError: () => setError(null), + }; +} diff --git a/vite.config.ts b/vite.config.ts index e35802e..b8fd38e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,6 +27,7 @@ export default defineConfig(({ mode }) => { "BYPASS_EMAIL_VERIFICATION", "POSTHOG_PUBLIC_KEY", "POSTHOG_HOST", + "TURNSTILE_SITE_KEY", ], server: { allowedHosts,