Implemented Cloudflare Turnstile protection for Payload form submissions#12
Implemented Cloudflare Turnstile protection for Payload form submissions#12cloverzer0 wants to merge 5 commits intomainfrom
Conversation
✅ Deploy Preview for cublueprint-platform ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
Pull request overview
Adds Cloudflare Turnstile CAPTCHA protection to Payload form submissions by embedding a client-side widget on both form entry points and verifying the token server-side before accepting submissions, replacing the legacy reCAPTCHA flow.
Changes:
- Added a reusable Turnstile widget component and wired it into both frontend form submission flows, sending the token via request headers.
- Added server-side Turnstile token verification utility and enforced verification via a
beforeValidatehook onform-submissions. - Documented/typed required environment variables for Turnstile (and updated
.env.exampleaccordingly).
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
src/utilities/verifyTurnstile.ts |
Adds server-side token verification against Cloudflare’s siteverify endpoint. |
src/plugins/index.ts |
Enforces verification for Payload form submissions via a beforeValidate hook. |
src/environment.d.ts |
Adds typings for Turnstile environment variables. |
src/components/TurnstileWidget.tsx |
Introduces a client-side component that loads Turnstile script and renders the widget. |
src/blocks/Form/Component.tsx |
Adds Turnstile widget + token gating to the generic form block submission flow. |
src/app/(frontend)/contact/_components/Form.tsx |
Adds Turnstile widget + token gating to the contact page form flow. |
.env.example |
Documents Turnstile keys (and adds additional env examples). |
Comments suppressed due to low confidence (1)
src/app/(frontend)/contact/_components/Form.tsx:133
loadingTimerIDis cleared only afterawait req.json(). Iffetchthrows, the timer can still fire and setisLoadingtotrueafter the error path runs. Clear the timeout in the catch path (or afinally) to prevent loading state from becoming inconsistent after network failures.
} catch (err) {
console.warn(err)
setIsLoading(false)
recaptchaRef.current?.reset()
setCaptchaToken(null)
// setError({
// message: 'Something went wrong.',
// })
ErrorToast('Error', "We couldn't send your message. Please try again later.")
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| function loadScript(): Promise<void> { | ||
| return new Promise((resolve) => { | ||
| if (typeof window === 'undefined') return resolve() | ||
| if ((window as any).turnstile) return resolve() | ||
| const existing = document.querySelector(`script[src="${SCRIPT_SRC}"]`) | ||
| if (existing) return resolve() | ||
| const s = document.createElement('script') | ||
| s.src = SCRIPT_SRC | ||
| s.async = true | ||
| s.defer = true | ||
| s.onload = () => resolve() | ||
| document.head.appendChild(s) |
There was a problem hiding this comment.
loadScript() resolves immediately when it finds an existing <script src="...api.js">, even if that script hasn’t finished loading yet. That can leave window.turnstile undefined and the initial render() attempt will throw and be swallowed, resulting in a widget that never appears. Consider waiting for the existing script’s load event (or polling until window.turnstile is defined) before resolving.
| function loadScript(): Promise<void> { | |
| return new Promise((resolve) => { | |
| if (typeof window === 'undefined') return resolve() | |
| if ((window as any).turnstile) return resolve() | |
| const existing = document.querySelector(`script[src="${SCRIPT_SRC}"]`) | |
| if (existing) return resolve() | |
| const s = document.createElement('script') | |
| s.src = SCRIPT_SRC | |
| s.async = true | |
| s.defer = true | |
| s.onload = () => resolve() | |
| document.head.appendChild(s) | |
| const TURNSTILE_READY_POLL_MS = 50 | |
| const TURNSTILE_READY_TIMEOUT_MS = 5000 | |
| function waitForTurnstileReady(script?: HTMLScriptElement | null): Promise<void> { | |
| return new Promise((resolve) => { | |
| if (typeof window === 'undefined') return resolve() | |
| if (window.turnstile) return resolve() | |
| let settled = false | |
| let intervalId: number | undefined | |
| let timeoutId: number | undefined | |
| const cleanup = () => { | |
| if (script) { | |
| script.removeEventListener('load', onLoad) | |
| script.removeEventListener('error', onError) | |
| } | |
| if (intervalId !== undefined) window.clearInterval(intervalId) | |
| if (timeoutId !== undefined) window.clearTimeout(timeoutId) | |
| } | |
| const finish = () => { | |
| if (settled) return | |
| settled = true | |
| cleanup() | |
| resolve() | |
| } | |
| const checkReady = () => { | |
| if (window.turnstile) finish() | |
| } | |
| const onLoad = () => { | |
| checkReady() | |
| } | |
| const onError = () => { | |
| finish() | |
| } | |
| if (script) { | |
| script.addEventListener('load', onLoad) | |
| script.addEventListener('error', onError) | |
| } | |
| intervalId = window.setInterval(checkReady, TURNSTILE_READY_POLL_MS) | |
| timeoutId = window.setTimeout(finish, TURNSTILE_READY_TIMEOUT_MS) | |
| checkReady() | |
| }) | |
| } | |
| function loadScript(): Promise<void> { | |
| return new Promise((resolve) => { | |
| if (typeof window === 'undefined') return resolve() | |
| if (window.turnstile) return resolve() | |
| const existing = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT_SRC}"]`) | |
| if (existing) { | |
| void waitForTurnstileReady(existing).then(resolve) | |
| return | |
| } | |
| const s = document.createElement('script') | |
| s.src = SCRIPT_SRC | |
| s.async = true | |
| s.defer = true | |
| document.head.appendChild(s) | |
| void waitForTurnstileReady(s).then(resolve) |
| useEffect(() => { | ||
| let mounted = true | ||
| void (async () => { | ||
| await loadScript() | ||
| if (!mounted) return | ||
| if (!containerRef.current) return | ||
| try { | ||
| widgetIdRef.current = window.turnstile.render(containerRef.current, { | ||
| sitekey: siteKey, | ||
| callback: (token: string) => onChange(token), | ||
| 'expired-callback': () => onChange(null), | ||
| }) | ||
| } catch (e) { | ||
| // script might not be ready; ignore | ||
| } | ||
| })() | ||
|
|
||
| return () => { | ||
| mounted = false | ||
| } | ||
| }, [siteKey, onChange]) |
There was a problem hiding this comment.
The effect re-runs whenever onChange changes (and callers currently pass an inline callback), but the cleanup doesn’t remove the existing Turnstile widget. This can lead to multiple widgets being rendered into the same container and leaking event handlers. Consider stabilizing the callback (e.g., store it in a ref) and/or guarding against re-rendering when widgetIdRef.current is already set, and call turnstile.remove(widgetIdRef.current) on cleanup.
| if (redirectUrl) router.push(redirectUrl) | ||
| } | ||
| } catch (err) { | ||
| console.warn(err) | ||
| setIsLoading(false) | ||
| setError({ | ||
| message: 'Something went wrong.', | ||
| }) | ||
| recaptchaRef.current?.reset() | ||
| setCaptchaToken(null) |
There was a problem hiding this comment.
loadingTimerID is only cleared after a successful fetch/json() path. If the request throws (network error, aborted request, etc.), the timeout can still fire after the catch block and flip isLoading back to true. Clear the timeout in the catch path (or in a finally) to avoid the UI getting stuck/spinning after failures.
| const [captchaToken, setCaptchaToken] = useState<string | null>(null) | ||
| const recaptchaRef = useRef<TurnstileHandle | null>(null) | ||
| const router = useRouter() |
There was a problem hiding this comment.
recaptchaRef now refers to a Turnstile widget. Renaming this ref (and related occurrences) to something like turnstileRef would avoid confusion with the removed reCAPTCHA implementation.
There was a problem hiding this comment.
This was already applied in commit 0528675 — recaptchaRef has been renamed to turnstileRef throughout src/blocks/Form/Component.tsx.
| // const [name, setName] = useState('') | ||
| // const recaptchaRef = useRef<ReCAPTCHA>(null); | ||
| const recaptchaRef = useRef<TurnstileHandle | null>(null) | ||
|
|
There was a problem hiding this comment.
recaptchaRef is now a ref to the Turnstile widget. Renaming it (e.g., turnstileRef) would make the code clearer and prevent confusion with the removed reCAPTCHA integration.
There was a problem hiding this comment.
Done. Renamed recaptchaRef to turnstileRef in both src/app/(frontend)/contact/_components/Form.tsx and src/blocks/Form/Component.tsx (commit 0528675).
| try { | ||
| const res = await fetch(turnstileVerifyURL, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/x-www-form-urlencoded', | ||
| }, | ||
| body, | ||
| cache: 'no-store', | ||
| }) |
There was a problem hiding this comment.
The Turnstile verification fetch has no timeout. If Cloudflare is slow/hangs, this can tie up the request and degrade reliability (especially in serverless runtimes). Consider using an AbortController with a short timeout and treating aborts as a temporary verification failure.
There was a problem hiding this comment.
Added an AbortController with a 5 s timeout to the Turnstile verification fetch. The timeout is cleared on both the success and error paths to avoid leaks, and any abort/network error falls through to the existing failure return (commit 6b7dcb1).
Co-authored-by: Copilot <[email protected]>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
src/app/(frontend)/contact/_components/Form.tsx:62
- There are debug
console.logstatements left in the submit flow. These will pollute production logs (and may expose form content in the browser console). Please remove them or gate them behind a development-only check.
console.log('onSubmit data', data)
let loadingTimerID: ReturnType<typeof setTimeout>
const submitForm = async () => {
console.log('submitForm data', data)
// setError(undefined)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const existing = document.querySelector(`script[src="${SCRIPT_SRC}"]`) | ||
| if (existing) return resolve() | ||
| const s = document.createElement('script') | ||
| s.src = SCRIPT_SRC | ||
| s.async = true | ||
| s.defer = true | ||
| s.onload = () => resolve() | ||
| document.head.appendChild(s) |
There was a problem hiding this comment.
loadScript() resolves immediately when it finds an existing <script> tag, even if the Turnstile script hasn’t finished loading yet. In that case window.turnstile will still be undefined and the widget render attempt will be skipped with no retry, leaving the CAPTCHA permanently blank. Consider awaiting the existing script’s load event (or polling until window.turnstile exists) before resolving.
| const existing = document.querySelector(`script[src="${SCRIPT_SRC}"]`) | |
| if (existing) return resolve() | |
| const s = document.createElement('script') | |
| s.src = SCRIPT_SRC | |
| s.async = true | |
| s.defer = true | |
| s.onload = () => resolve() | |
| document.head.appendChild(s) | |
| const resolveWhenReady = (script: HTMLScriptElement) => { | |
| if (window.turnstile) { | |
| resolve() | |
| return | |
| } | |
| const cleanup = () => { | |
| clearInterval(intervalId) | |
| script.removeEventListener('load', handleReady) | |
| script.removeEventListener('error', handleError) | |
| } | |
| const handleReady = () => { | |
| if (!window.turnstile) return | |
| cleanup() | |
| resolve() | |
| } | |
| const handleError = () => { | |
| cleanup() | |
| resolve() | |
| } | |
| const intervalId = window.setInterval(() => { | |
| if (window.turnstile) { | |
| cleanup() | |
| resolve() | |
| } | |
| }, 50) | |
| script.addEventListener('load', handleReady) | |
| script.addEventListener('error', handleError) | |
| } | |
| const existing = document.querySelector(`script[src="${SCRIPT_SRC}"]`) as HTMLScriptElement | null | |
| if (existing) { | |
| resolveWhenReady(existing) | |
| return | |
| } | |
| const s = document.createElement('script') | |
| s.src = SCRIPT_SRC | |
| s.async = true | |
| s.defer = true | |
| document.head.appendChild(s) | |
| resolveWhenReady(s) |
| useEffect(() => { | ||
| let mounted = true | ||
| void (async () => { | ||
| await loadScript() | ||
| if (!mounted) return | ||
| if (!containerRef.current) return | ||
| try { | ||
| const t = typeof window !== 'undefined' ? window.turnstile : undefined | ||
| if (t && containerRef.current) { | ||
| widgetIdRef.current = t.render(containerRef.current, { | ||
| sitekey: siteKey, | ||
| callback: (token: string) => onChange(token), | ||
| 'expired-callback': () => onChange(null), | ||
| }) | ||
| } else { | ||
| // Turnstile not available yet; we'll skip rendering for now | ||
| // it should be available after the script loads | ||
| } | ||
| } catch { | ||
| // script might not be ready; ignore | ||
| } | ||
| })() | ||
|
|
||
| return () => { | ||
| mounted = false | ||
| } | ||
| }, [siteKey, onChange]) |
There was a problem hiding this comment.
The effect depends on onChange, but both call sites pass an inline callback, so onChange changes every render. This will re-run the effect and call turnstile.render repeatedly (potentially rendering multiple widgets or throwing if the container is already rendered). Consider stabilizing the callback (e.g., store onChange in a ref) and ensure you only render once per mount / siteKey change, with appropriate cleanup/reset on reruns.
| async ({ data, req }) => { | ||
| const captchaToken = req.headers.get('x-captcha-token') ?? undefined | ||
|
|
||
| const verificationResult = await verifyTurnstileToken(captchaToken) | ||
|
|
There was a problem hiding this comment.
This hook enforces a CAPTCHA token for all form-submission creates/updates, including authenticated/admin requests (which won’t send x-captcha-token). If admins or server-side code need to create submissions, add a bypass for trusted contexts (e.g., when req.user is present) or scope the check to public/unauthenticated requests only.
Agent-Logs-Url: https://github.com/Carleton-Blueprint/cuBlueprint/sessions/85db620d-c758-42f7-8004-c3c9937ba534 Co-authored-by: cloverzer0 <[email protected]>
Agent-Logs-Url: https://github.com/Carleton-Blueprint/cuBlueprint/sessions/8d10690b-8290-4249-898c-158f74c31063 Co-authored-by: cloverzer0 <[email protected]>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function loadScript(): Promise<void> { | ||
| return new Promise((resolve) => { | ||
| if (typeof window === 'undefined') return resolve() | ||
| if (window.turnstile) return resolve() | ||
| const existing = document.querySelector(`script[src="${SCRIPT_SRC}"]`) | ||
| if (existing) return resolve() | ||
| const s = document.createElement('script') | ||
| s.src = SCRIPT_SRC | ||
| s.async = true | ||
| s.defer = true | ||
| s.onload = () => resolve() | ||
| document.head.appendChild(s) | ||
| }) |
There was a problem hiding this comment.
loadScript() resolves immediately when it finds an existing Turnstile <script> tag, even if it hasn't finished loading yet. In that case window.turnstile may still be undefined, the render attempt gets skipped, and the widget never renders (no retry). Consider resolving only after the script's load event has fired (or after window.turnstile becomes available), and handling onerror to avoid a permanently pending Promise on script load failure.
| @@ -1,9 +1,9 @@ | |||
| 'use client' | |||
| "use client" | |||
There was a problem hiding this comment.
This file now uses double quotes for the Next.js directive ("use client"), but the repo Prettier config enforces singleQuote: true (.prettierrc.json). Please switch back to single quotes so formatting stays consistent with the rest of the codebase.
| "use client" | |
| 'use client' |
| turnstileRef.current?.reset() | ||
| setCaptchaToken(null) |
There was a problem hiding this comment.
turnstileRef.current?.reset() and setCaptchaToken(null) are indented as if they were still part of the ErrorToast(...) call, which is misleading and easy to break during edits. Please unindent these statements to match the surrounding block and improve readability.
| turnstileRef.current?.reset() | |
| setCaptchaToken(null) | |
| turnstileRef.current?.reset() | |
| setCaptchaToken(null) |
Description of Changes
This change adds client-side Turnstile widgets to both form entry points in the app and sends the generated token with each submission request. On the server, form submissions now go through a verification hook before validation, which checks the token against Cloudflare’s Turnstile API and rejects the submission if the token is missing, invalid, or unavailable. The legacy reCAPTCHA dependency and implementation were removed as part of the switch.
Related Issues
Checklist
Changelog entry has been madeand useful to others. I will ensure this by editing the final commit message
in GitHub prior to merging
If a checklist item is completed for this MR, place an
xinside of the squarebrackets for that item. If a checklist item is not applicable for this MR, please
note that by wrapping that line with
~characters,like this.