Turnstile stuck on "Verifying you are human": causes and fixes
The widget hangs because the challenge never completed, and in nearly every case there is an error code behind it that nothing on your page is showing you. The usual causes are challenges.cloudflare.com being unreachable or blocked by an extension, a Content-Security-Policy that omits it, a wrong device clock, a widget rendered into a hidden container, or a headless browser that Cloudflare declines to issue a token to.
Get the error code before you guess
A spinning Turnstile widget is not silent — it is unreported. Turnstile passes an error code to error-callback, and if you have not wired that callback, nothing surfaces it. Attach it first, then debug.
turnstile.render('#turnstile', { sitekey: '0x4AAAAAAAAAAAAAAAAAAAAA', callback: (token) => { document.querySelector('#signup').submit(); }, 'error-callback': (code) => { // The first three digits are the error family: // 110 = configuration, 200 = environment, 300/600 = challenge failure. console.error('turnstile error', code, 'family', Math.floor(code / 1000)); return false; // falsy return: Turnstile also logs its own console warning }, });
Returning a falsy value (including undefined) from error-callback makes Turnstile log a warning to the console containing the error code. Return something truthy only once you handle the error yourself, because Turnstile then assumes it is handled and stops logging.
Two things you will see in DevTools that are documented as normal and are not your bug: 401 responses on Private Access Token requests, and DNS lookup failures on subdomains of *.challenges.cloudflare.com. Cloudflare states both are expected and non-blocking. Do not spend an afternoon on them.
Causes and fixes
| Cause | What you see | Fix |
|---|---|---|
challenges.cloudflare.com unreachable — corporate firewall, DNS filter, transparent proxy | Error 200500 "Iframe load error"; the widget iframe never loads in the Network tab | Allow challenges.cloudflare.com end to end. This is the single most common cause of a permanent spinner. |
| CSP omits Cloudflare's host | Console: "Refused to load the script … turnstile/v0/api.js" or a frame-src violation | Add the host to script-src and frame-src, or use a nonce. See the next section. |
| Ad blocker, privacy extension or NoScript | Spinner forever, often with no console error at all | Reproduce in a clean profile with extensions disabled. If it clears, it is the visitor's browser, not your integration. |
| Device clock wrong, or the page was cached by an intermediary | Error 200100 "Clock or cache problem" (no retry) or 110600 "Challenge timed out" (retries) | Turn on automatic time sync. Confirm no CDN or reverse-proxy cache is serving a stale page containing a per-visit widget. |
execution set to execute but turnstile.execute() is never called | The widget renders and waits | Call turnstile.execute(widgetId) from your submit handler, or revert execution to its default render. |
Widget rendered into a display: none container — modal, tab, accordion | Blank box, or a spinner that never resolves after the container is shown | Render explicitly after the container becomes visible, and reset rather than re-render on reopen. |
| Hostname not on the sitekey | Error 110200 "Domain not authorized" | Add the hostname under Hostname Management for that widget. |
| Wrong or disabled sitekey | 110100, 110110, 400020 (invalid sitekey) or 400070 (sitekey disabled) | Correct it in the Turnstile dashboard. |
| Interactive challenge appeared, visitor never clicked it | Error 110620 "Interaction timed out" | Call turnstile.reset(), and wire timeout-callback so this resets automatically. |
| Headless or automated browser | 300* / 600* generic challenge failure, or an endless spinner | Cloudflare treats these as bot behaviour. Use test sitekeys in CI — see below. |
The modal case deserves its own snippet, because it accounts for a large share of "it works on the page but hangs in the popup" reports. Load api.js with ?render=explicit, then render once the container has real dimensions.
// <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" // async defer></script> let widgetId; openButton.addEventListener('click', () => { modal.show(); // container must be visible and laid out before render() if (widgetId !== undefined) { turnstile.reset(widgetId); // reopening: reset, never render twice return; } widgetId = turnstile.render('#turnstile-container', { sitekey: window.TURNSTILE_SITEKEY, callback: (token) => { form.dataset.token = token; }, 'error-callback': (code) => { console.error('turnstile', code); return false; }, 'timeout-callback': () => turnstile.reset(widgetId), }); });
The CSP directives Turnstile actually needs
Cloudflare's own recommendation is the nonce-based approach: put your nonce on the api.js script tag and Turnstile propagates it to the resources it loads dynamically. That is what makes Turnstile work under strict-dynamic.
<!--
Nonce approach (recommended by Cloudflare):
Content-Security-Policy: script-src 'nonce-r4nd0m' 'strict-dynamic';
frame-src https://challenges.cloudflare.com;
Host-allowlist approach, if you are not using nonces:
Content-Security-Policy: script-src 'self' https://challenges.cloudflare.com;
frame-src 'self' https://challenges.cloudflare.com;
connect-src 'self';
-->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"
nonce="r4nd0m" async defer></script>
<div class="cf-turnstile" data-sitekey="0x4AAAAAAAAAAAAAAAAAAAAA"></div>Without nonces, Cloudflare documents exactly two additions: https://challenges.cloudflare.com in script-src and in frame-src. If you use pre-clearance, connect-src must also include 'self', because the fetch that sets the cf_clearance cookie goes to your own origin. Note that you cannot set a custom CSP via meta tags or Transform Rules on Cloudflare's own challenge pages.
Clocks, cached challenges and sandboxed iframes
Challenge payloads are time-bound. Error 200100 means the visitor's clock is wrong or the challenge was cached by an intermediary, and it is marked as not retryable — the widget will not recover on its own. Error 110600 ("the visitor's clock may be wrong, or the challenge took too long") is retryable. On a developer machine or a CI runner this is worth a literal check: run date -u on the box. Suspended VMs and containers without a working time sync drift far enough to break challenges within days.
The cache half of 200100 is a site-owner bug. If a full-page cache, a reverse proxy or an aggressive CDN rule is caching the HTML that embeds the widget, visitors receive someone else's challenge state. Exclude pages carrying a Turnstile widget from HTML caching, or render the widget client-side.
If your page is itself embedded in an iframe with a sandbox attribute, the Turnstile iframe inherits those restrictions. Without allow-scripts the challenge cannot execute at all — the console shows a blocked script execution in a sandboxed frame. If the challenge needs to request storage access, the embedding frame additionally needs allow-same-origin and allow-storage-access-by-user-activation.
Third-party cookie blocking is blamed more often than it deserves. Cloudflare does not document a third-party cookie requirement for the widget itself, so a visitor blocking third-party cookies is rarely the direct cause of a hanging widget. Clearance is a different matter: pre-clearance issues a cf_clearance cookie on your own zone, and JavaScript Detections requires the browser to store and transmit that cookie on subsequent requests. A visitor who blocks cookies for your site will loop on a full-page challenge even though the widget itself would have worked.
The full-page interstitial is a different failure
Two distinct things display "Verifying you are human": the Turnstile widget you embedded in a form, and Cloudflare's full-page WAF challenge served from /cdn-cgi/challenge-platform/. If the entire page is the challenge and your form is nowhere in the DOM, your widget configuration is irrelevant — you are debugging the WAF.
- Check the URL and the DOM. An interstitial replaces the whole document; a stuck widget sits inside your own page.
- An interstitial that loops means
cf_clearanceis not sticking: cookies blocked, the cookie stripped by a proxy, or the client IP changing between requests. - Your origin's CSP can break the interstitial too. Allow anything under
/cdn-cgi/challenge-platform/, and allow scripts from your own origin (script-src 'self'). Cloudflare parses and supports the nonces it injects, but JavaScript Detections is not supported with anonceset via a<meta>tag. - As the site owner, check which WAF rules and paths trigger the challenge before changing anything about Turnstile. Clearance can also be reduced or invalidated mid-session if the session is scored as suspicious, so a re-challenge on a valid cookie is not necessarily a bug.
Headless browsers, Playwright and CI
If the widget resolves in your browser and hangs under Playwright or Puppeteer, that is the product doing its job. Turnstile scores the browser environment, WebDriver-controlled browsers set navigator.webdriver by specification, and the documented outcome for detected automation is a 300* or 600* generic challenge failure — or a widget that never issues a token. Stealth plugins and launch flags are a treadmill: they work until Cloudflare's detection updates, then your suite breaks with no code change on your side.
For your own site the supported answer is Cloudflare's test keys. They work on any domain, including localhost, and they always issue a token.
// config/turnstile.js — pick keys by environment, never by hostname sniffing const isProd = process.env.NODE_ENV === 'production'; export const turnstile = isProd ? { sitekey: process.env.TURNSTILE_SITEKEY, secret: process.env.TURNSTILE_SECRET, } : { // Always passes, visible widget. sitekey: '1x00000000000000000000AA', // Always passes validation; accepts only XXXX.DUMMY.TOKEN.XXXX. secret: '1x0000000000000000000000000000000AA', }; // Other useful test sitekeys: // 2x00000000000000000000AB always fails (visible) // 1x00000000000000000000BB always passes (invisible) // 3x00000000000000000000FF forces an interactive challenge
Test secret keys accept only the dummy token XXXX.DUMMY.TOKEN.XXXX, and production secret keys reject that token. A leaked test sitekey therefore fails closed rather than opening a hole — but still gate the choice on environment, not on anything the browser can influence.
Then fix your wait condition. Waiting for a spinner to disappear is fragile. Wait for the hidden response field the widget writes into your form — cf-turnstile-response by default.
import { test, expect } from '@playwright/test'; test('signup submits once Turnstile issues a token', async ({ page }) => { await page.goto(`${process.env.BASE_URL}/signup`); // Poll the hidden input the widget populates, not the visual spinner. await expect .poll( () => page.locator('input[name="cf-turnstile-response"]').inputValue(), { timeout: 15_000 }, ) .not.toBe(''); await page.fill('#email', 'qa@example.com'); await page.click('button[type="submit"]'); await expect(page.locator('#welcome')).toBeVisible(); });
If your CI hits a full-page WAF challenge rather than a widget, the equivalent lever is a WAF custom rule that skips the challenge for your CI egress IP or a secret header. Where neither applies — an anti-bot regression test, or uptime monitoring against a production sign-up flow you cannot repoint at a test key — SolveGate returns a token for managed, non-interactive and invisible Turnstile widgets from a single POST /v1/solve with gate, sitekey and url, typically in under 1.5s, with Node and Python SDKs. Use it only against properties you own or are authorised to test. For ordinary E2E coverage of your own app, the test sitekeys remain the better answer: they are free, deterministic, and never go stale.
Triage order
- Open the console. Is there a Turnstile error code? If yes, act on that code and stop guessing.
- Network tab: did
turnstile/v0/api.jsload, and did the widget iframe document load? A failed iframe is200500territory — something is blockingchallenges.cloudflare.com. - Search the console for "Refused to load" or "Refused to execute". That is CSP, not Cloudflare.
- Retry in a clean profile with extensions off. If it still hangs, the visitor's browser is not the problem.
- Run
date -u. More than a minute of skew is enough to produce200100or110600. - Confirm the container is visible and laid out when
render()runs, and thatexecutionis not set toexecutewithout a matchingturnstile.execute()call. - Confirm the hostname is listed under Hostname Management for that sitekey.
- Swap in
1x00000000000000000000AAand reload. If the test sitekey also hangs, the fault is in your page — script loading, CSP, container visibility. If it resolves instantly, the fault is in scoring or reachability for that client.
Common questions
The challenge started but never returned a token. The common causes are challenges.cloudflare.com being blocked by a firewall, extension or Content-Security-Policy; a device clock that is wrong; the widget being rendered into a hidden container; or an automated browser that Cloudflare declines to issue a token to. Wire an error-callback to get the numeric code, then fix the specific cause rather than the symptom.
Pass an error-callback to turnstile.render() or set data-error-callback on the widget element. The callback receives the error code as its first argument. If the callback returns a falsy value, Turnstile also logs a warning containing the code to the browser console, which is the fastest way to see it during development.
Cloudflare recommends putting your CSP nonce on the api.js script tag; Turnstile propagates it to the resources it loads, so it works with strict-dynamic. If you are not using nonces, add https://challenges.cloudflare.com to both script-src and frame-src. If you use pre-clearance, connect-src must also include 'self'.
Cloudflare does not document a third-party cookie requirement for the widget itself, so third-party cookie blocking is rarely the direct cause of a hanging widget. Clearance is different: pre-clearance issues a cf_clearance cookie on your own zone, and JavaScript Detections needs the browser to store and send it on later requests. A visitor blocking cookies for your site will therefore loop on a full-page challenge even if the widget alone would have succeeded.
Turnstile scores the browser environment, and WebDriver-controlled browsers advertise themselves — navigator.webdriver is true by specification. Detected automation gets a 300* or 600* generic challenge failure, or no token at all. Stealth plugins and Chrome launch flags are unreliable over time because detection changes without notice.
Use Cloudflare's test keys instead of disabling it. Sitekey 1x00000000000000000000AA always passes and works on any domain including localhost, paired with secret key 1x0000000000000000000000000000000AA on the server. Test secret keys accept only the dummy token XXXX.DUMMY.TOKEN.XXXX, and production secret keys reject that token, so the two environments cannot cross-validate.
Related
More in Turnstile errors
Automating a gate you own or are authorised to test?
// SolveGate clears Cloudflare Turnstile and WAF challenges through one REST call · first 1,000 solves free