Skip to content

Commit 6f671f1

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp): optional Cloudflare Turnstile check on magic-link sign-in
Adds an optional Cloudflare Turnstile bot check to magic-link sign-in. Set `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY` and the login page's email form shows a Turnstile widget, invisible for most visitors. The server verifies its token before sending a magic link. Requests with a missing or invalid token are refused. If Cloudflare can't be reached, the request is allowed and an error is logged, so email sign-in keeps working during an outage. It's off unless both keys are set. Mono-RevId: d220edeb85b98f7afb3e2074e004769c198f88e7
1 parent 6910cd4 commit 6f671f1

10 files changed

Lines changed: 626 additions & 5 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Self-hosted instances can add a Cloudflare Turnstile bot check to the magic-link sign-in form by setting `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY`. It's off unless both are set.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { useEffect, useRef } from "react";
2+
3+
export type TurnstileStatus = "pending" | "ready" | "error";
4+
5+
type TurnstileApi = {
6+
render(container: HTMLElement, options: Record<string, unknown>): string | undefined;
7+
remove(widgetId: string): void;
8+
};
9+
10+
declare global {
11+
interface Window {
12+
turnstile?: TurnstileApi;
13+
}
14+
}
15+
16+
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
17+
18+
let scriptPromise: Promise<TurnstileApi> | undefined;
19+
20+
function loadTurnstile(): Promise<TurnstileApi> {
21+
if (window.turnstile) {
22+
return Promise.resolve(window.turnstile);
23+
}
24+
25+
scriptPromise ??= new Promise<TurnstileApi>((resolve, reject) => {
26+
const script = document.createElement("script");
27+
script.src = SCRIPT_SRC;
28+
script.async = true;
29+
script.onload = () => {
30+
if (window.turnstile) {
31+
resolve(window.turnstile);
32+
} else {
33+
scriptPromise = undefined;
34+
reject(new Error("Turnstile didn't initialize"));
35+
}
36+
};
37+
script.onerror = () => {
38+
scriptPromise = undefined;
39+
script.remove();
40+
reject(new Error("Turnstile failed to load"));
41+
};
42+
document.head.appendChild(script);
43+
});
44+
45+
return scriptPromise;
46+
}
47+
48+
/**
49+
* Renders a Cloudflare Turnstile widget inside the surrounding form. Turnstile
50+
* adds a hidden `cf-turnstile-response` input to the container, so the token is
51+
* submitted with the form. Tokens are single use: remount the widget (change its
52+
* `key`) after each submission.
53+
*/
54+
export function TurnstileWidget({
55+
siteKey,
56+
action,
57+
onStatusChange,
58+
}: {
59+
siteKey: string;
60+
action: string;
61+
onStatusChange?: (status: TurnstileStatus) => void;
62+
}) {
63+
const containerRef = useRef<HTMLDivElement>(null);
64+
const onStatusChangeRef = useRef(onStatusChange);
65+
66+
useEffect(() => {
67+
onStatusChangeRef.current = onStatusChange;
68+
}, [onStatusChange]);
69+
70+
useEffect(() => {
71+
let cancelled = false;
72+
let widgetId: string | undefined;
73+
const report = (status: TurnstileStatus) => {
74+
if (!cancelled) onStatusChangeRef.current?.(status);
75+
};
76+
77+
report("pending");
78+
loadTurnstile()
79+
.then((turnstile) => {
80+
if (cancelled || !containerRef.current) return;
81+
widgetId = turnstile.render(containerRef.current, {
82+
sitekey: siteKey,
83+
action,
84+
theme: "dark",
85+
appearance: "interaction-only",
86+
"refresh-expired": "auto",
87+
callback: () => report("ready"),
88+
"expired-callback": () => report("pending"),
89+
"error-callback": () => report("error"),
90+
});
91+
})
92+
.catch(() => report("error"));
93+
94+
return () => {
95+
cancelled = true;
96+
if (widgetId) {
97+
window.turnstile?.remove(widgetId);
98+
}
99+
};
100+
}, [siteKey, action]);
101+
102+
return <div ref={containerRef} className="flex w-full justify-center empty:hidden" />;
103+
}

‎apps/webapp/app/env.server.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,8 @@ const EnvironmentSchema = z
406406
.refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.")
407407
.optional(),
408408
BLOCKED_EMAIL_DOMAINS: z.string().optional(),
409+
TURNSTILE_SITE_KEY: z.string().optional(),
410+
TURNSTILE_SECRET_KEY: z.string().optional(),
409411
ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(),
410412
// Instance-level kill switch for the admin dashboard and user impersonation.
411413
ADMIN_DASHBOARD_ENABLED: BoolEnv.default(true),

‎apps/webapp/app/routes/login._index/route.tsx‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { parseWithZod } from "@conform-to/zod/v4";
33
import { EnvelopeIcon, LockClosedIcon } from "@heroicons/react/20/solid";
44
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
55
import { Form, useNavigation } from "@remix-run/react";
6+
import { useEffect, useRef, useState } from "react";
67
import { GitHubLightIcon } from "@trigger.dev/companyicons";
78
import { motion, useReducedMotion } from "framer-motion";
89
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -18,6 +19,8 @@ import { Input } from "~/components/primitives/Input";
1819
import { InputGroup } from "~/components/primitives/InputGroup";
1920
import { Paragraph } from "~/components/primitives/Paragraph";
2021
import { Spinner } from "~/components/primitives/Spinner";
22+
import { TurnstileWidget, type TurnstileStatus } from "~/components/TurnstileWidget";
23+
import { env } from "~/env.server";
2124
import { TextLink } from "~/components/primitives/TextLink";
2225
import { isGithubAuthSupported, isGoogleAuthSupported } from "~/services/auth.server";
2326
import { getLastAuthMethod } from "~/services/lastAuthMethod.server";
@@ -101,6 +104,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
101104
const prefilledEmail = getAuthorizationEmail(redirectTo);
102105
const lastAuthMethod = await getLastAuthMethod(request);
103106

107+
const turnstileSiteKey =
108+
env.TURNSTILE_SITE_KEY && env.TURNSTILE_SECRET_KEY ? env.TURNSTILE_SITE_KEY : null;
109+
104110
const notice =
105111
url.searchParams.get("reason") === SSO_SESSION_EXPIRED_REASON
106112
? "Your SSO session expired. Please sign in again."
@@ -157,6 +163,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
157163
authError,
158164
notice,
159165
prefilledEmail,
166+
turnstileSiteKey,
160167
isVercelMarketplace: redirectTo.startsWith("/vercel/callback"),
161168
},
162169
{ headers }
@@ -172,6 +179,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
172179
authError,
173180
notice,
174181
prefilledEmail,
182+
turnstileSiteKey,
175183
isVercelMarketplace: false,
176184
},
177185
{ headers }
@@ -204,6 +212,19 @@ export default function LoginPage() {
204212
navigation.formAction === "/login/magic" &&
205213
navigation.formData?.get("action") === "send";
206214

215+
const [turnstileStatus, setTurnstileStatus] = useState<TurnstileStatus>("pending");
216+
const [turnstileKey, setTurnstileKey] = useState(0);
217+
const wasSendingRef = useRef(false);
218+
useEffect(() => {
219+
if (isEmailLoading) {
220+
wasSendingRef.current = true;
221+
} else if (wasSendingRef.current) {
222+
wasSendingRef.current = false;
223+
setTurnstileKey((key) => key + 1);
224+
}
225+
}, [isEmailLoading]);
226+
const waitingForTurnstile = data.turnstileSiteKey !== null && turnstileStatus !== "ready";
227+
207228
const [emailForm, emailFields] = useForm({
208229
id: "login-email",
209230
defaultValue: {
@@ -323,12 +344,26 @@ export default function LoginPage() {
323344
{emailFields.email.errors}
324345
</FormError>
325346
</InputGroup>
347+
{data.turnstileSiteKey && (
348+
<TurnstileWidget
349+
key={turnstileKey}
350+
siteKey={data.turnstileSiteKey}
351+
action="magic-link"
352+
onStatusChange={setTurnstileStatus}
353+
/>
354+
)}
355+
{data.turnstileSiteKey && turnstileStatus === "error" && (
356+
<FormError>
357+
We couldn't load the verification check. Refresh the page or use another
358+
sign-in option.
359+
</FormError>
360+
)}
326361
<div className="relative w-full">
327362
{data.lastAuthMethod === "email" && <LastUsedBadge />}
328363
<Button
329364
type="submit"
330365
variant="primary/large"
331-
disabled={isEmailLoading}
366+
disabled={isEmailLoading || waitingForTurnstile}
332367
fullWidth
333368
data-action="continue with email"
334369
>

‎apps/webapp/app/routes/login.magic/route.tsx‎

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@ import {
3232
canonicalizeEmailForRateLimit,
3333
} from "~/services/magicLinkRateLimiter.server";
3434
import { ssoRedirectForEmail } from "~/services/ssoAutoDiscovery.server";
35-
import { logger, tryCatch } from "@trigger.dev/core/v3";
35+
import { tryCatch } from "@trigger.dev/core/v3";
36+
import { logger } from "~/services/logger.server";
3637
import { env } from "~/env.server";
3738
import { extractClientIp } from "~/utils/extractClientIp.server";
3839
import { magicLinkEmailCookie } from "./magicLinkEmailCookie.server";
40+
import { TURNSTILE_RESPONSE_FIELD, verifyTurnstileToken } from "~/services/turnstile.server";
3941
import { keepFlashedErrorOnTimezoneSave } from "~/utils/flashedErrorRevalidation";
4042

4143
export const shouldRevalidate = keepFlashedErrorOnTimezoneSave;
@@ -166,11 +168,53 @@ export async function action({ request }: ActionFunctionArgs) {
166168
switch (data.action) {
167169
case "send": {
168170
const { email } = data;
171+
const clientIp = extractClientIp(request.headers.get("x-forwarded-for"));
172+
173+
if (env.TURNSTILE_SITE_KEY && env.TURNSTILE_SECRET_KEY) {
174+
const token = payload[TURNSTILE_RESPONSE_FIELD];
175+
const verification = await verifyTurnstileToken({
176+
secretKey: env.TURNSTILE_SECRET_KEY,
177+
token: typeof token === "string" ? token : undefined,
178+
remoteIp: clientIp,
179+
});
169180

170-
if (env.LOGIN_RATE_LIMITS_ENABLED) {
171-
const xff = request.headers.get("x-forwarded-for");
172-
const clientIp = extractClientIp(xff);
181+
if (verification.outcome === "failed") {
182+
if (verification.misconfigured) {
183+
logger.error(
184+
"Login magic link Turnstile secret is misconfigured, refusing the request",
185+
{
186+
clientIp,
187+
errorCodes: verification.errorCodes,
188+
}
189+
);
190+
} else {
191+
logger.warn("Login magic link Turnstile check failed", {
192+
clientIp,
193+
errorCodes: verification.errorCodes,
194+
});
195+
}
196+
197+
const session = await getUserSession(request);
198+
session.flash("auth:error", {
199+
message: "We couldn't verify this request. Please try again.",
200+
});
173201

202+
return redirect("/login", {
203+
headers: {
204+
"Set-Cookie": await commitSession(session),
205+
},
206+
});
207+
}
208+
209+
if (verification.outcome === "unavailable") {
210+
logger.error("Login magic link Turnstile check unavailable, allowing the request", {
211+
clientIp,
212+
reason: verification.reason,
213+
});
214+
}
215+
}
216+
217+
if (env.LOGIN_RATE_LIMITS_ENABLED) {
174218
// Key the buckets on the canonical address so `+tag` aliases (and
175219
// Gmail dot variants) of one inbox share it. Delivery still uses the
176220
// raw submitted address below.

0 commit comments

Comments
 (0)