Add Cloudflare Turnstile captcha on email signup (#326)

This commit is contained in:
Ben Senescu 2026-07-01 09:32:27 -04:00 committed by Ben Senescu
parent 97f026562e
commit fffdbc9329
5 changed files with 254 additions and 37 deletions

View File

@ -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 <TurnstileWidget />.
export function useTurnstileCaptcha() {
const tokenRef = useRef<string | null>(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<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(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<HTMLScriptElement>(
`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 <div ref={containerRef} className="flex justify-center" />;
}

6
src/env.d.ts vendored
View File

@ -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;
}

View File

@ -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: {

View File

@ -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<string | null>(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 (
<AuthPageCard
title="Create your account"
@ -158,7 +159,7 @@ function SignUpPage() {
className="text-sm text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
onClick={() => {
setShowEmailForm(false);
setSocialError(null);
google.clearError();
}}
>
Back to signup
@ -207,17 +208,17 @@ function SignUpPage() {
<AuthMethodChooser
googleLabel="Continue with Google"
disabled={!isHostedMode}
isBusy={isStartingGoogle}
isBusy={google.isStarting}
onContinueWithGoogle={() => {
void handleContinueWithGoogle();
void google.start();
}}
onContinueWithEmail={() => {
setShowEmailForm(true);
setSocialError(null);
google.clearError();
}}
/>
{socialError ? (
<p className="text-sm text-error">{socialError}</p>
{google.error ? (
<p className="text-sm text-error">{google.error}</p>
) : null}
</>
) : (
@ -327,6 +328,13 @@ function SignUpPage() {
}}
</form.Field>
{isTurnstileEnabled ? (
<TurnstileWidget
onToken={captcha.onToken}
resetNonce={captcha.resetNonce}
/>
) : null}
<form.Subscribe
selector={(state) => ({
submitError: state.errorMap.onSubmit,
@ -342,7 +350,11 @@ function SignUpPage() {
) : null}
<button
className="btn btn-soft w-full"
disabled={!isHostedMode || isSubmitting}
disabled={
!isHostedMode ||
isSubmitting ||
(isTurnstileEnabled && !captcha.hasToken)
}
>
{isSubmitting ? "Creating account..." : "Create account"}
</button>
@ -355,3 +367,49 @@ function SignUpPage() {
</AuthPageCard>
);
}
// 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<string | null>(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),
};
}

View File

@ -27,6 +27,7 @@ export default defineConfig(({ mode }) => {
"BYPASS_EMAIL_VERIFICATION",
"POSTHOG_PUBLIC_KEY",
"POSTHOG_HOST",
"TURNSTILE_SITE_KEY",
],
server: {
allowedHosts,