Add Cloudflare Turnstile invisible challenge to AI Gateway requests#71603
Conversation
Dynamically loads the Turnstile script on first use (no HTML changes needed), fetches an invisible challenge token, and attaches it as X-Turnstile-Token header on every generateText and transcribe request to the AI Gateway worker. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
Adds turnstile_site_key to config.yml.erb (global default, same key for all environments) and surfaces it to the frontend via a script data attribute in the application layout, matching the existing pattern used for Statsig keys. turnstile.ts now reads the key from the DOM rather than hardcoding it. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
Adds turnstile-site-key to DCDO.frontend_config so it is available to the frontend via DCDO.get() through the existing DCDO script tag, with no changes to application.html.haml needed. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
Remove the previous widget before rendering a new one so retries after worker failures don't leave orphaned widgets that loop internally. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
…overy - Encapsulate all widget state (widgetId, pending callbacks) in a private class so it can only be accessed through the serialized promise chain - Chain ensures only one challenge runs at a time, preventing concurrent callers from racing on shared state - 30s timeout per challenge: cleans up the widget and rejects so the chain advances and queued callers are not permanently blocked - settled flag prevents timeout and token callback from both firing - DOM health check before reset(); falls back to fresh render if widget was detached; clears container.innerHTML before render to evict orphaned content https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
Revert to the same widget lifecycle used in the last working implementation: always remove the existing widget and render a fresh one. The previous reset() approach and container.innerHTML='' were behavioral changes that did not exist in the working code and appear to have broken the integration. Also simplifies the settle pattern by inlining it into the callback — no need for pendingResolve/pendingReject fields since the only caller of settle is the inline callback and the timeout. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
Turnstile fires a `debugger` call inside an anonymous Web Worker script. If DevTools is open with breakpoints active on anonymous scripts, that pause breaks the challenge and the request times out or returns an invalid token. Probe for this condition before rendering the widget by running a `debugger` statement inside `new Function(...)` (same anonymous-script context Turnstile uses) and measuring elapsed time with performance.now(). If elapsed > 100ms a human resumed the debugger, meaning Turnstile will also be blocked. On detection, throw TurnstileDevToolsError (exported) so callers can instanceof-check and surface a targeted message: - Deactivate breakpoints (Ctrl/Cmd+F8), or - DevTools Settings → Ignore List → enable anonymous scripts If new Function() is blocked by CSP the probe catches and returns false (assumes safe), so production environments are unaffected. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
When the DevTools debugger probe fires (breakpoints active on anonymous
scripts), instead of silently hanging or timing out:
turnstile.ts:
- Log console.error with a clear explanation of why the challenge fails
- Log a console.group with three fix options:
1. Close DevTools
2. (collapsed) Step-by-step guide to ignore anonymous scripts
3. Deactivate breakpoints with Ctrl/Cmd+F8
- Throw TurnstileDevToolsError (existing export) after logging
submitChatContents.ts:
- Import TurnstileDevToolsError
- Skip metrics logError for TurnstileDevToolsError (expected condition,
not a server fault) alongside the existing 403 skip
- Add TurnstileDevToolsError branch that shows the targeted chat message:
"Tutor chat messages cannot be sent due to your browser's dev tools
being open. Please close dev tools and try again or see message in
dev tools for other options."
- Update generic error dispatch to pass commonI18n.aiChatResponseError()
as chatMessageText directly (no longer uses 'error' sentinel)
ChatMessageView.tsx:
- Remove hardcoded commonI18n.aiChatResponseError() override for
Status.ERROR so the chatMessageText field is used directly, enabling
per-error custom messages while keeping the same visual error styling
https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
The previous new Function() approach ran debugger on the main thread,
which meant the user HAD to click Resume in DevTools before we could
measure elapsed time and detect the issue. That's the same bad UX we
were trying to avoid.
New approach: spin up a Blob Worker that runs `debugger; postMessage('ok')`.
- Worker is a sourceless anonymous script → same context as Turnstile's
own Web Worker, so the Ignore List setting applies identically.
- If DevTools is pausing on anonymous scripts the Worker suspends, the
message never arrives, and the 100ms timeout fires → we terminate the
Worker (which clears the DevTools pause automatically) → resolve true.
- If no pause, the Worker posts immediately (microseconds) → resolve false.
- Main thread is never blocked. User never needs to click Resume.
- If Blob/Worker creation fails (CSP) we catch and resolve false (assume safe).
runChallenge() is now async to await the Promise<boolean> probe.
https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
…ification for DevTools error The previous commit changed getChatMessageDisplayText to pass chatMessageText through for Status.ERROR, but aichatApi.ts dispatches Status.ERROR assistant messages with chatMessageText: modelResponse (raw server text) that was intentionally hidden by the i18n override. Exposing modelResponse directly was a regression. Changes: - Revert ChatMessageView.tsx Status.ERROR case back to always returning commonI18n.aiChatResponseError(), preserving the existing override behavior - Revert generic error dispatch chatMessageText back to 'error' sentinel - Change the TurnstileDevToolsError handler to dispatch a Notification (notificationType: 'error') instead of a ChatMessage — consistent with how rate limit errors are shown, and avoids needing to change the Status.ERROR display logic https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
…s probe The TurnstileManager refactor (4b81a97, 2457f73) broke the tool and was reverted locally. This merge resolution keeps the simple proven module-level activeWidgetId pattern from before those commits, while retaining all the DevTools detection work added afterwards: - debuggerWillPauseInAnonymousScope() Blob Worker probe - TurnstileDevToolsError (exported for instanceof checks in callers) - console.error + console.group guidance when DevTools blocks Turnstile - 30s CHALLENGE_TIMEOUT_MS on the render callback - No TurnstileManager class, no serialized promise chain https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
Clearing activeWidgetId in the token callback meant the second call to getTurnstileToken() skipped remove() and rendered into an occupied container. Turnstile warned "already been rendered in this container", the new widget's callback never fired, and the 30s timeout triggered. Keep activeWidgetId set after the first render so subsequent calls always remove the previous widget before rendering a fresh one. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
The error was thrown inside the dynamically-imported aichat-client-api chunk (via getClientApi()), which bundles its own copy of turnstile.ts. This made instanceof TurnstileDevToolsError fail in the statically- imported submitChatContents.ts because the two class objects differ across chunk boundaries. Switch to checking error.name === 'TurnstileDevToolsError', which works regardless of which chunk the error originated from. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
- F1 is the most reliable way to open Settings (gear icon location varies) - Clarify Ignore List is in the left sidebar and may need scrolling - Note that older Chrome called it "Blackboxing" - Clarify how to close Settings (Escape/✕) https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
Users can confuse the Console panel's settings gear with the main DevTools Settings gear. Step 1 now explicitly calls this out and recommends F1 to avoid any ambiguity. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
"Anonymous scripts from eval or console" only covers eval() and console-typed scripts, not Blob Worker scripts. Both our probe and Cloudflare Turnstile run inside Blob Workers, so that setting has no effect. Removed Option 2 (Ignore List) and simplified to the two options that actually work: close DevTools or press Ctrl+F8/Cmd+F8. Also added an explicit NOTE explaining why the Ignore List doesn't apply, so developers don't waste time trying it. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
…t limits - Recommend clicking the Sources panel button directly (more reliable than keyboard shortcut, especially on Mac where F8 = media key) - Clarify Mac keyboard shortcut requires Fn key: Fn+Cmd+F8 - Update Ignore List NOTE: it doesn't apply to Worker contexts at all, not just "Anonymous scripts" — no pattern (including blob:) can suppress debugger statements inside Web Workers https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
A single chat message makes 3+ calls to getTurnstileToken (safety check on user input, main generation, safety check on model output). Previously each call removed the old widget and rendered a brand-new challenge from scratch, so calls 2 and 3 always had to wait for a fresh challenge and had a window for token expiry between the render and the request. Now after consuming a token we immediately kick off renderFreshToken() in the background. The next call grabs the already-in-flight promise (or a fresh one if none is pending) and clears it before awaiting so concurrent callers can't accidentally share the same one-time-use token. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
Recovers the good structural ideas from commits 4b81a97/2457f7328 (which were reverted due to buggy reset()/innerHTML='' calls) and merges them with the current working remove()+render() approach and the pre-fetch optimization added in a96a492. What's restored: - TurnstileManager class encapsulating all widget state (widgetId, chain, nextToken) so nothing leaks into module scope - Promise chain serialization: only one widget renders at a time; chain always advances on rejection so queued callers never block - settled flag in runChallenge() prevents timeout and token callback from both firing if they race at the 30s boundary What's kept from current working code: - remove() + render() for each challenge (NOT reset()) - Pre-fetch: after each token is delivered, schedulePrefetch() enqueues the next challenge so subsequent calls within one chat message (safety check → main generation → output safety check) get a token that is already completing rather than waiting on a fresh challenge https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
Every step of token creation is now logged with the orange circle prefix so testers can filter the console by 🟠 to see the full Turnstile flow. Success paths logged: - getTurnstileToken() entry + total elapsed on delivery - DevTools probe duration (pass and blocked) - Script: already cached / first inject / loaded - getToken() pre-fetch hit vs miss - Challenge enqueued + starting (gap = wait time behind chain) - Previous widget removal + container child count before/after - render() call + widgetId assigned + container child count after - Token callback: token length + ms since render() - Pre-fetch resolved + token length Failure paths logged (all re-thrown except intentional swallows): - DevTools probe: Worker onerror (swallowed, assume safe) - DevTools probe: CSP/unsupported blocked (swallowed, assume safe) - DevTools blocked → TurnstileDevToolsError thrown - Script load failed (logged, reject propagates) - getTurnstileToken() catch: error + elapsed before rethrow - remove() threw: error + rethrow - Container non-empty after remove(): child count warning - Container non-empty before render(): widget accumulation warning - render() threw: error + rethrow (timeout cleared first) - render() returned falsy widgetId - TIMEOUT: 30s expired — widgetId + container child count - remove() in timeout handler threw (logged, not rethrown in cleanup) - Double-settle #1: token callback after timeout — discarded + len - Double-settle #2: timeout after callback — no-op - Pre-fetch failed (swallowed, nextToken cleared — speculative) Structural fixes: - Container created in TurnstileManager constructor, appended directly to document.body (outside React tree, can never be unmounted) - Stored as private field — all child-count checks use the same node, no re-querying by id on each call https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
Public Cloudflare Turnstile sitekey doesn't need per-environment config or frontend_config bridging — just a TS constant in turnstile.ts. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
…eriment
- Private constructor + static getInstance() — nothing runs at import time
- getTurnstileToken() moves from standalone export to instance method
- Call sites check experiments.isEnabledAllowingQueryString('useTurnstile')
before calling getInstance(); experiment off → no instance, no DOM div,
no script, no widget, no header
- Enable via ?enableExperiments=useTurnstile (persists to localStorage)
https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
sanchitmalhotra126
left a comment
There was a problem hiding this comment.
Good stuff! A few minor comments
| const headers: Record<string, string> = { | ||
| 'Content-Type': 'application/json', | ||
| }; | ||
| if (turnstileToken) headers['X-Turnstile-Token'] = turnstileToken; |
There was a problem hiding this comment.
nit: could wrap this up in a single util that returns a turnstileHeaders or similar, which would just be empty if the experiment is disabled, since this code is repeated for both endpoints?
Also I imagine we'd want to move our auth token into a header eventually as well? At which point you could just have a single function that produces all the auth headers needed.
There was a problem hiding this comment.
Note: the main reason I haven't jumped to tokens in headers is keeping streaming in mind. Might still make sense in headers for non-streaming HTTP requests but just want to see the whole picture first.
There was a problem hiding this comment.
done, moved to turnstileHeaders
| * - Breakpoints are deactivated (Ctrl+F8) | ||
| * - Worker / Blob URL creation is blocked by CSP (can't detect → assume safe) | ||
| */ | ||
| function debuggerWillPauseInAnonymousScope(): Promise<boolean> { |
| * Instantiated lazily via getInstance() — nothing executes until the first | ||
| * call, ensuring zero side effects when the experiment is disabled. | ||
| */ | ||
| export class TurnstileManager { |
There was a problem hiding this comment.
Might be good to add tests for this, especially to verify the lifecycle goals above
There was a problem hiding this comment.
ok will look into it. I just added this today. This was just an attempt to be clean but the critical piece is that if disabled it will not ever block the user.
There was a problem hiding this comment.
Ok added a small test file covering:
- turnstileHeaders — returns correct header when token present, empty object when null
- fetchTurnstileTokenIfEnabled — getInstance not called when experiment off, called when on (ensures no effect when off as everything flows through singleton)
- TurnstileManager.getInstance() — returns same instance on repeated calls (singleton invariant)
- constants.ts: all top-level consts - types.ts: TurnstileDevToolsError + Window declaration - debuggerProbe.ts: debuggerWillPauseInAnonymousScope - loadScript.ts: loadTurnstileScript + scriptLoadPromise - manager.ts: TurnstileManager singleton class - util.ts: fetchTurnstileTokenIfEnabled + turnstileHeaders (eliminates duplication between generateText.ts and transcribe.ts) - index.ts: public re-exports only generateText.ts and transcribe.ts simplified to single-line calls. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
…ariants Covers: - turnstileHeaders: correct header when token present, empty when null - fetchTurnstileTokenIfEnabled: getInstance not called when experiment off, called and token returned when experiment on - TurnstileManager.getInstance: same instance on repeated calls, container div appended to body on first call, no duplicate containers on repeat calls Widget lifecycle (serialization, pre-fetch, settled-flag race) deferred — requires mocking window.turnstile callbacks at controlled timing, out of scope while feature is experimental and human-tested via 🟠 logging. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
Centralizes the error.name string comparison and the explanation of why instanceof cannot be used across webpack chunk boundaries. submitChatContents now imports the predicate rather than repeating the raw check with comments. https://claude.ai/code/session_011kxb8TDhtsuXEetzZomJQP
Add Cloudflare Turnstile bot protection to AI Gateway requests
Adds Cloudflare Turnstile invisible bot-detection to AI Gateway chat and transcription requests. When active, each request carries a short-lived challenge token that the gateway backend can use to verify the request originated from a real browser session.
The feature is off by default and gated behind an experiment flag for (human) testing. The gateway backend is currently set to validate the token if sent, so enabling on the frontend allows full testing of the feature, end-to-end.
Enabling
User flow
Implementation notes
Zero cost when disabled —
TurnstileManageris a lazy singleton (private constructor+static getInstance()). If the experiment is off,getInstance()is never called: no DOM node, no Cloudflare script, no widget, no header.DevTools probe — Cloudflare's own anti-bot check fires a
debuggerstatement inside an anonymous Blob Worker. If DevTools breakpoints are active on anonymous scripts, Turnstile would hang indefinitely. Our probe detects this in 100ms and fails fast with a clear user message and console guidance, rather than silently blocking the request. The probe self-terminates cleanly — the user is never left with a paused debugger to dismiss.Background pre-fetch — after each token is delivered, the next challenge begins immediately in the background. On the next message the token is already in-progress or ready, avoiding a cold-start wait. If the background challenge fails or times out it is silently discarded and the next real call runs a fresh challenge.
TurnstileDevToolsErrordetection — checked byerror.namestring rather thaninstanceofbecausegetClientApi()is a dynamic webpack import (separate chunk), giving it its own copy of the class. Cross-chunkinstanceofalways returns false.Devtools Probe Error UI
Files changed
apps/src/aiGateway/turnstile/manager.ts,debuggerProbe.ts,loadScript.ts,util.ts,types.ts,constants.ts,index.tsapps/src/aiGateway/generateText.tsfetchTurnstileTokenIfEnabled()+turnstileHeaders()apps/src/aiGateway/transcribe.tsapps/src/aichat/redux/thunks/submitChatContents.tsapps/test/unit/aiGateway/turnstile/turnstileTest.tsTesting
Filter browser console for
🟠to trace the full challenge lifecycle. To exercise the DevTools conflict path: enable the experiment, open DevTools Sources panel, activate breakpoints, then send a chat message.