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,