Skip to content

Implemented Cloudflare Turnstile protection for Payload form submissions#12

Open
cloverzer0 wants to merge 5 commits intomainfrom
feat/turnstile-captcha
Open

Implemented Cloudflare Turnstile protection for Payload form submissions#12
cloverzer0 wants to merge 5 commits intomainfrom
feat/turnstile-captcha

Conversation

@cloverzer0
Copy link
Copy Markdown

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

  • Closes [ISSUE NUMBER HERE]

Checklist

  • MR title is meaningful and accurate
  • This MR has an associated GitHub Issues ticket
  • GitHub Issues ticket fix version for this issue is correct
  • Changelog entry has been made
  • I promise that my commit message for this MR will be clear, meaningful,
    and 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 x inside of the square
brackets for that item. If a checklist item is not applicable for this MR, please
note that by wrapping that line with ~ characters, like this.

Make sure you squash your commits before merging!

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 28, 2026

Deploy Preview for cublueprint-platform ready!

Name Link
🔨 Latest commit 6b7dcb1
🔍 Latest deploy log https://app.netlify.com/projects/cublueprint-platform/deploys/69f121d813e5cd000898d7c5
😎 Deploy Preview https://deploy-preview-12--cublueprint-platform.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 beforeValidate hook on form-submissions.
  • Documented/typed required environment variables for Turnstile (and updated .env.example accordingly).

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

  • loadingTimerID is cleared only after await req.json(). If fetch throws, the timer can still fire and set isLoading to true after the error path runs. Clear the timeout in the catch path (or a finally) 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.

Comment thread .env.example Outdated
Comment on lines +20 to +32

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)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +72
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])
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 124 to +133
if (redirectUrl) router.push(redirectUrl)
}
} catch (err) {
console.warn(err)
setIsLoading(false)
setError({
message: 'Something went wrong.',
})
recaptchaRef.current?.reset()
setCaptchaToken(null)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +48 to 50
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const recaptchaRef = useRef<TurnstileHandle | null>(null)
const router = useRouter()
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already applied in commit 0528675recaptchaRef has been renamed to turnstileRef throughout src/blocks/Form/Component.tsx.

Comment on lines 21 to 23
// const [name, setName] = useState('')
// const recaptchaRef = useRef<ReCAPTCHA>(null);
const recaptchaRef = useRef<TurnstileHandle | null>(null)

Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Renamed recaptchaRef to turnstileRef in both src/app/(frontend)/contact/_components/Form.tsx and src/blocks/Form/Component.tsx (commit 0528675).

Comment on lines +39 to +47
try {
const res = await fetch(turnstileVerifyURL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
cache: 'no-store',
})
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.log statements 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.

Comment thread .env.example
Comment on lines +37 to +44
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)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +64 to +90
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])
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/plugins/index.ts
Comment on lines +66 to +70
async ({ data, req }) => {
const captchaToken = req.headers.get('x-captcha-token') ?? undefined

const verificationResult = await verifyTurnstileToken(captchaToken)

Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +33 to +45
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)
})
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@@ -1,9 +1,9 @@
'use client'
"use client"
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
"use client"
'use client'

Copilot uses AI. Check for mistakes.
Comment on lines +102 to +103
turnstileRef.current?.reset()
setCaptchaToken(null)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
turnstileRef.current?.reset()
setCaptchaToken(null)
turnstileRef.current?.reset()
setCaptchaToken(null)

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants