Failed to load the Turnstile script
Either api.js never executes, or it executes too late and turnstile.render() throws turnstile is not defined. Both present as "the widget just is not there". The four causes worth checking are a Cloudflare optimisation rewriting your script tag, a Content Security Policy missing a directive, an extension or network blocking challenges.cloudflare.com, and an ordering mistake with explicit rendering. The first is the one nobody expects, because it is Cloudflare breaking Cloudflare.
Rocket Loader, the cause nobody looks for
Rocket Loader is a Cloudflare speed feature, on by default in some configurations, and it does exactly what it says: it defers the loading of all of your JavaScript until after rendering, inline and external alike. Cloudflare's own description is that it "handles both inline and external scripts, while maintaining order of execution".
That is fine for most scripts and wrong for this one. Turnstile's implicit rendering mode depends on api.js running and finding cf-turnstile containers in the document; explicit rendering depends on api.js having defined window.turnstile before your onload callback fires. Rewriting when scripts run rewrites both of those assumptions, and the failure is intermittent enough to look like a network problem.
Cloudflare's own limitations note is blunt about the general case: "If you observe JavaScript or jQuery issues for your website, disable Rocket Loader and retest your website." That is the diagnostic. Turn it off, reload, and if the widget appears you have your answer in thirty seconds.
The fix is not to leave it off. Exclude the one script instead:
<!-- data-cfasync="false" tells Rocket Loader to leave this tag alone. --> <script data-cfasync="false" defer src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script> <div class="cf-turnstile" data-sitekey="0x4AAAAAAA_your_key"></div>
If a whole page is affected rather than one tag, Cloudflare's documented alternative is a Configuration Rule excluding that URL. Note that data-cfasync="false" has to be on the tag itself — adding it to a wrapper, or setting it from script after the fact, does nothing.
Rocket Loader also has a CSP consequence that is easy to miss. It loads its own runtime from ajax.cloudflare.com, so a policy that does not allow that origin blocks Rocket Loader itself — which can leave every deferred script on the page, Turnstile included, never running at all.
A Content Security Policy missing a directive
Cloudflare states it directly: "Without the correct CSP directives, Turnstile may fail to load." Two directives are the minimum:
| Directive | Value | What breaks without it |
|---|---|---|
script-src | https://challenges.cloudflare.com | api.js is blocked outright — nothing renders |
frame-src | https://challenges.cloudflare.com | The script runs, the challenge iframe is refused, the widget hangs |
connect-src | 'self' | Pre-clearance only — the cf_clearance fetch fails silently |
The two failure shapes are worth telling apart. A missing script-src entry means api.js never runs, so window.turnstile is undefined and nothing appears. A missing frame-src entry means the script runs fine and the iframe is refused — the container appears, then does nothing, which reads as a hung widget rather than a load failure and sends people down the wrong path entirely.
Cloudflare recommends the nonce-based approach over an allowlist: put your nonce on the api.js tag and Turnstile propagates it to the resources it loads. It also works with strict-dynamic. One caveat from their reference worth knowing before you try to fix this with infrastructure: you cannot set your own CSP through meta tags or Transform Rules on challenge pages.
Every CSP failure names itself in the console — Refused to load the script or Refused to frame, with the directive that blocked it. Read that line before changing anything; it identifies the exact directive.
Blocked by an extension, a network, or a filter
Cloudflare's troubleshooting sequence starts here for a reason. Their first three steps are browser compatibility, then extensions — "Some browser extensions, such as ad blockers, may block the scripts Turnstile needs to operate" — then JavaScript being enabled at all. Private mode, a different browser, no VPN and a different network follow.
When the iframe specifically cannot load, Turnstile has a code for it: 200500, documented as "Iframe load error", with the instruction to check whether challenges.cloudflare.com is blocked. It is marked retryable. If you see 200500 rather than nothing at all, the script loaded and the challenge frame did not — which points at frame-src or a network filter, not at the script tag.
One thing that looks like this and is not: DNS lookup failures on subdomains of challenges.cloudflare.com. Cloudflare documents those as part of normal execution and explicitly non-blocking, and advises against surfacing them as fatal — particularly in WebView and handler-based integrations, where the recommended approach is to drop network errors from the *.challenges.cloudflare.com wildcard while keeping visibility on the apex. A HAR full of red that ends in a working token is not a load failure.
Explicit rendering, called too early
With ?render=explicit, api.js no longer scans the document — you call turnstile.render() yourself. That makes the ordering yours to get right, and turnstile is not defined is what getting it wrong looks like.
<!-- Wrong: turnstile may not exist yet when this runs. --> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script> <script>turnstile.render("#box", { sitekey: "0x4AAAAAAA_your_key" });</script> <!-- Right: name a callback and let api.js invoke it when it is ready. --> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onTurnstileLoad" defer></script> <script> function onTurnstileLoad() { turnstile.render("#box", { sitekey: "0x4AAAAAAA_your_key", callback: function (token) { console.log("token", token.length, "chars"); }, "error-callback": function (code) { console.log("turnstile error", code); } }); } </script>
The callback has to be reachable as a global by the name in the query string. Defined inside a module, a bundler scope or a framework component, it is not — which is the usual reason this pattern fails in a build that worked as a plain HTML file. In a single-page app, render on mount and keep the container out of the framework's control, since the iframe and the hidden input are inserted into it from outside.
If you are trying to establish whether a page carries Turnstile at all, and with what configuration, our Turnstile checker reads the served HTML and reports the sitekey, the rendering mode and the widget's attributes.
A five-minute order of checks
- Console first.
Refused to loadnames the CSP directive.turnstile is not definedis an ordering problem. Silence with no widget points at the script tag never running. - Network panel. Is
api.jsrequested at all? Not requested means the tag was rewritten or removed — Rocket Loader, a tag manager, a sanitiser. Requested and failed means CSP or a blocker. - View source, not the DOM inspector. The inspector shows the page after scripts have run. Source shows what the server sent, which is where a rewritten
type="text/rocketscript"attribute is visible. - Turn Rocket Loader off and reload. Thirty seconds, and it settles the most surprising cause outright.
- Try a private window with extensions disabled. Cloudflare's own step 2 and 4, and it separates "broken for everyone" from "broken for me".
If the script loads and the widget still fails, the problem has moved on from loading. A numeric code in error-callback is the fastest route from there: the error index maps each one to its cause, and a widget that mounts and then stops responding is error 300030 rather than a load failure at all.
Common questions
Rocket Loader defers every script on the page so content renders sooner, and it cannot know that this one has ordering requirements. Turnstile's implicit mode needs api.js to run and find its containers; explicit mode needs window.turnstile to exist before your onload callback. Excluding the tag with data-cfasync="false" keeps both features.
script-src and frame-src must both allow https://challenges.cloudflare.com. Pre-clearance additionally needs 'self' in connect-src, because the clearance cookie is set by a fetch to a /cdn-cgi/ endpoint on your own domain. Cloudflare recommends a nonce on the api.js tag instead of an allowlist, and Turnstile works with strict-dynamic.
No — that is usually frame-src rather than script-src. The script running and the challenge iframe being refused produces an empty container, not a missing one, and Turnstile reports it as error 200500, documented as an iframe load error with the advice to check whether challenges.cloudflare.com is blocked.
Almost certainly not. Cloudflare documents those DNS lookup failures as part of Turnstile's normal execution and explicitly non-blocking, and advises against treating them as fatal — especially in WebView embeddings. If a token is being issued, they are noise.
Because it ran before api.js finished. With render=explicit, pass onload=yourCallback in the script URL and do the rendering inside that function — and make sure the callback is a real global, not a name scoped inside a module or bundler output, which is why this often breaks only after a build step.
Related
Sources
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