Add Cloudflare Turnstile to grant applications - #909
Conversation
Canonical fail-closed verification against challenges.cloudflare.com using TURNSTILE_SECRET and the request token/IP.
Gate `/api/github` and `/api/sendgrid` on canonical siteverify before creating issues or sending mail. Strip the token from the email body.
Show the challenge on the final step, require a token before submit, and refresh the token between GitHub and SendGrid siteverify calls.
Document NEXT_PUBLIC_TURNSTILE_SITE_KEY and TURNSTILE_SECRET, and reject unverified requests in the SendGrid and GitHub API suites.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Adds Cloudflare Turnstile bot protection to the shared grant application flow, enforcing server-side verification on both application submission endpoints while keeping the client submit button gated on token readiness.
Changes:
- Introduces shared Turnstile utilities for token extraction, client IP detection, and canonical
siteverify(fail-closed). - Updates client submission flow to require Turnstile and refresh the challenge between
/api/githuband/api/sendgridcalls (single-use tokens). - Gates both API routes with Turnstile verification, strips the token from the SendGrid internal email body, and adds Jest coverage.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| utils/turnstile.ts | Adds shared Turnstile helpers (token extraction, IP detection, siteverify, API-route assertion). |
| utils/turnstile.test.js | Unit tests for Turnstile helpers and fail-closed behavior. |
| utils/application-submission.ts | Submits with Turnstile tokens; refreshes token between GitHub and SendGrid submissions. |
| utils/application-submission.test.js | Tests fresh-token behavior and token-required gating in submission flow. |
| tests/api/sendgrid.test.js | Adds Turnstile gating tests and asserts token is removed from internal email content. |
| tests/api/github.test.js | Adds Turnstile gating tests for GitHub issue creation endpoint. |
| pages/api/sendgrid.ts | Enforces Turnstile verification and filters token out of the internal email body. |
| pages/api/github.ts | Enforces Turnstile verification before creating GitHub issues. |
| components/grant-application/TurnstileWidget.tsx | New explicit-render Turnstile widget with imperative handle (wait/reset). |
| components/grant-application/MultiStepApplicationForm.tsx | Renders Turnstile on the final step and disables submit until verification is ready. |
| .env.example | Documents NEXT_PUBLIC_TURNSTILE_SITE_KEY and TURNSTILE_SECRET. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Reset no longer enqueues waiters on fire-and-forget paths, and leaving the final step clears turnstileReady so submit stays gated.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (4)
utils/application-submission.ts:67
githubTokenfalls back toString(data[TURNSTILE_TOKEN_FIELD] || ''), which treats non-string values (e.g. an object) as a non-empty token ("[object Object]") and skips the "complete the bot verification" error, leading to a misleading submission failure. Since the submission data isunknown, the fallback should only accept a non-empty string token.
const githubToken = turnstile
? await turnstile.waitForToken()
: String(data[TURNSTILE_TOKEN_FIELD] || '')
utils/turnstile.ts:60
verifyTurnstileTokenhas no timeout; if Cloudflare stalls, the API route can hang until the platform kills the request, tying up resources and increasing tail latency. Add a short abort timeout so verification fails closed quickly on slow upstream responses.
const response = await fetchImpl(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
pages/api/sendgrid.ts:277
- If
TURNSTILE_SECRETis missing/misconfigured,assertTurnstilereturnsfalseand the handler responds 403, which is indistinguishable from a real bot failure and will cause users to retry indefinitely. Consider treating a missing secret as an internal misconfiguration (500) and only returning 403 when verification genuinely fails.
if (!(await assertTurnstile(req))) {
return res.status(403).json({ message: TURNSTILE_FAILURE_MESSAGE })
}
if (!SENDGRID_API_KEY || !TO_ADDRESS || !FROM_ADDRESS) {
throw new Error('Env misconfigured')
pages/api/github.ts:22
- If
TURNSTILE_SECRETis missing/misconfigured,assertTurnstilereturnsfalseand this route responds 403, which will look like a user/bot problem rather than an internal deployment problem. Consider validating required env (includingTURNSTILE_SECRET) first and only returning 403 when siteverify actually fails.
if (!(await assertTurnstile(req))) {
return res.status(403).json({ message: TURNSTILE_FAILURE_MESSAGE })
}
if (!GH_ACCESS_TOKEN || !GH_ORG || !GH_APP_REPO) {
throw new Error('Env misconfigured')
Explicit render never initialized when Script onLoad missed under Next.js dynamic imports, leaving submit disabled with no widget.
script-src and frame-src blocked challenges.cloudflare.com, so the widget never loaded on apply forms.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (2)
utils/application-submission.ts:85
- Same issue as
githubToken:String(data[TURNSTILE_TOKEN_FIELD] || '')can produce a non-empty string for non-string values. Restrict this to a non-empty string token.
const emailToken = turnstile
? await turnstile.resetAndWaitForToken()
: String(data[TURNSTILE_TOKEN_FIELD] || '')
utils/application-submission.ts:67
- When
turnstilecontrols aren't provided,String(data[TURNSTILE_TOKEN_FIELD] || '')can turn non-string values into a non-empty token (e.g.[object Object]), which bypasses the "complete the challenge" check and leads to a confusing server-side 403. Only accept a non-empty string here.
This issue also appears on line 82 of the same file.
const githubToken = turnstile
? await turnstile.waitForToken()
: String(data[TURNSTILE_TOKEN_FIELD] || '')
Explicit-render the widget so leaving and returning to the last step shows a fresh challenge. Send application emails from /api/github after issue create so the form no longer waits for a second mid-submit verify.
|
Thanks @BoltTouring - should be fixed now/soon, please have another look. We are in no rush to merge this btw, applications should be fine, this is just a proper anti-spam measure. Even if we merge this on Monday (or even Wednesday or whatever) - that's soon enough. |
Arvin21M
left a comment
There was a problem hiding this comment.
Tested on mobile view:
- We should consider adding "Security Auditing/Testing" as an option under the "Main Focus" dropdown if we want to accept Red Team or similar applications through this channel as well.
- I encountered Cloudflare error code 600010 during testing. This may be related to using the real configuration in the build preview instead of dummy/test credentials, so may not be an issue once pushed to production.
- Console message screenshot attached for reference fwiw.
- Test application is not coming through on the back end, likely due to the cloudflare fail/error issue described above (which is good, anti-spam wise).
- Otherwise, the application experience looks good from a user perspective,

Superseded by follow-up Security grant focus work in PR #911.
|
Let's make sure this works properly before we merge. |
|
All of the issues I found have been resolved. LGTM |


Adds Cloudflare Turnstile to the grant application form. Submit stays disabled until the challenge succeeds, then
/api/githuband/api/sendgridverify the token server-side before creating the issue or sending mail. CSP is updated so the widget can load.cf-turnstileon the final apply stepNEXT_PUBLIC_TURNSTILE_SITE_KEYandTURNSTILE_SECRETBuild preview: