Skip to content

Add Cloudflare Turnstile invisible challenge to AI Gateway requests#71603

Merged
edcodedotorg merged 44 commits into
stagingfrom
claude/add-turnstile-protection-Fiwc3
Apr 24, 2026
Merged

Add Cloudflare Turnstile invisible challenge to AI Gateway requests#71603
edcodedotorg merged 44 commits into
stagingfrom
claude/add-turnstile-protection-Fiwc3

Conversation

@edcodedotorg

@edcodedotorg edcodedotorg commented Mar 24, 2026

Copy link
Copy Markdown
Contributor

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

?enableExperiments=useTurnstile   ← persists to localStorage across page loads
?useTurnstile=1                   ← single session only

User flow

User sends a chat message
         │
         ▼
 useTurnstile experiment on?
         │
    No ──┴──────────────────────────────────────────────►  Request sent normally
                                                           (no Turnstile header)
    Yes
         │
         ▼
 DevTools probe  ←── invisible 100ms Worker fires debugger,
         │            waits for reply — detects if breakpoints
         │            would block Cloudflare's own anti-bot check
         │
  Blocked├──────────────────────────────────────────────►  Inline chat error:
  (breakpoints        🟠 console guidance group               "Chat messages cannot
   active)            with fix instructions                    be sent due to your
                      (close DevTools or Ctrl+F8)              browser's dev tools..."
         │
  Clear  │
         ▼
 Cloudflare invisible challenge  ←── completely hidden from user;
         │                            Cloudflare script loaded once,
         │                            cached for all subsequent calls
         │
  Token ─┴──────────────────────────────────────────────►  X-Turnstile-Token header
  ready                                                     attached to request
         │
         ▼
 Background pre-fetch starts  ←── next token challenge begins immediately
                                   so the following message doesn't wait

Implementation notes

Zero cost when disabledTurnstileManager is 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 debugger statement 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.

TurnstileDevToolsError detection — checked by error.name string rather than instanceof because getClientApi() is a dynamic webpack import (separate chunk), giving it its own copy of the class. Cross-chunk instanceof always returns false.

Devtools Probe Error UI

Screenshot 2026-04-23 at 12 38 35 PM

Files changed

File Change
apps/src/aiGateway/turnstile/ New directory — full Turnstile integration split across manager.ts, debuggerProbe.ts, loadScript.ts, util.ts, types.ts, constants.ts, index.ts
apps/src/aiGateway/generateText.ts fetchTurnstileTokenIfEnabled() + turnstileHeaders()
apps/src/aiGateway/transcribe.ts Same as above
apps/src/aichat/redux/thunks/submitChatContents.ts DevTools error notification + skip error reporting for expected client-side failures
apps/test/unit/aiGateway/turnstile/turnstileTest.ts Unit tests for util functions, experiment gate, singleton invariants

Testing

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.

claude and others added 30 commits March 24, 2026 14:18
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
claude and others added 3 commits April 20, 2026 17:08
…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 sanchitmalhotra126 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good stuff! A few minor comments

Comment thread apps/src/aichat/redux/thunks/submitChatContents.ts Outdated
Comment thread apps/src/aiGateway/generateText.ts Outdated
Comment on lines +97 to +100
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (turnstileToken) headers['X-Turnstile-Token'] = turnstileToken;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@edcodedotorg edcodedotorg Apr 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done, moved to turnstileHeaders

Comment thread apps/src/aiGateway/turnstile.ts Outdated
* - Breakpoints are deactivated (Ctrl+F8)
* - Worker / Blob URL creation is blocked by CSP (can't detect → assume safe)
*/
function debuggerWillPauseInAnonymousScope(): Promise<boolean> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Neat!

Comment thread apps/src/aiGateway/turnstile/manager.ts
Comment thread apps/src/aiGateway/turnstile.ts Outdated
* Instantiated lazily via getInstance() — nothing executes until the first
* call, ensuring zero side effects when the experiment is disabled.
*/
export class TurnstileManager {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Might be good to add tests for this, especially to verify the lifecycle goals above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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)

edcodedotorg and others added 9 commits April 22, 2026 15:12
- 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

@sanchitmalhotra126 sanchitmalhotra126 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM!

@edcodedotorg edcodedotorg merged commit 03a4d42 into staging Apr 24, 2026
8 checks passed
@edcodedotorg edcodedotorg deleted the claude/add-turnstile-protection-Fiwc3 branch April 24, 2026 00:31
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