Insights / DevOps

Cloudflare Turnstile never renders inside a collapsed <details> — and it never retries

Insight · 2026-08-18 · 5 min read

If you put a Cloudflare Turnstile widget inside a collapsed <details> element, it will never render — and opening the element later will not fix it. The widget's hidden input appears in the DOM, so the form looks correct, but no challenge iframe is created and no token is ever issued. If your server verifies that token, every genuine submission is rejected with a 403 while the form appears to work perfectly.

We shipped exactly this bug on our own contact page and it cost us real submissions before we caught it. The mechanism is simple once you see it, and it is not documented anywhere obvious.

What happens

Turnstile's implicit rendering works by scanning the document for elements with the cf-turnstile class when api.js loads, then rendering a widget into each one.

That scan runs once.

An element inside a closed <details> has no layout box at that moment. It is skipped. And the scan is not repeated — there is no observer watching for elements that become visible later, so opening the <details> a second, a minute, or an hour after page load changes nothing.

The failure is silent in the worst possible way. You get:

  • The <div class="cf-turnstile"> container, present and empty
  • No <iframe> from challenges.cloudflare.com
  • A cf-turnstile-response input that exists but stays empty forever

A form that looks complete, and a token that never arrives.

Why it is dangerous rather than merely annoying

On its own, a missing widget is a cosmetic problem. It becomes an outage the moment your server does its job.

Server-side verification is not optional — the widget on the page stops nothing by itself, because a bot posts directly to your API and never runs your JavaScript. The only thing that actually blocks it is checking the token server-side. So any correctly built form has a server that rejects a missing token.

Combine the two and you get a form that:

  1. Renders normally, with no visible error
  2. Accepts everything the visitor types
  3. Fails on submit with a generic error
  4. Leaves the visitor with no idea why, and no way to succeed by retrying

For a contact form, that is not a bug. That is your inbound pipeline quietly set to zero, with nothing in your logs to distinguish it from a quiet week.

How we caused it

We moved a long enquiry form behind a collapsed disclosure to reduce friction on mobile:

<details>
  <summary>Have more detail to share?</summary>
  <form>
    ...
    <div class="cf-turnstile" data-sitekey="..."></div>
    <button type="submit">Send enquiry</button>
  </form>
</details>

Reasonable-looking change. The form still rendered, the page still passed every check we had, and the endpoint had verified Turnstile for weeks without incident. Nothing in the diff suggested the anti-bot check would break.

The fix

Keep any form carrying a Turnstile widget visible at page load.

Not behind a <details>. Not in an inactive tab panel. Not inside a modal that mounts hidden. If the container has no layout box when api.js runs, the widget does not exist.

We removed the disclosure and showed the form. That also turned out to be better for visitors — a muted-grey summary line does not read as a control on a phone, and two thirds of our visitors are on phones — but the correctness reason is the one that matters.

If you genuinely cannot show the form at load, you need explicit rendering: load api.js with ?render=explicit and call turnstile.render() yourself once the container has a box, driven by an IntersectionObserver or the toggle event. Be warned that this path is more fragile than it looks, and test it against production rather than localhost, where the sitekey rejects the hostname anyway.

Two hours we lost to a false negative, and the lesson in it

This is the part worth reading even if you never touch Turnstile.

While debugging, our automated test browser stopped receiving tokens entirely — including on a page with a single, fully visible widget, in the exact configuration that had worked an hour earlier. We took that as evidence that our fix was wrong and nearly reverted a change that was correct.

It was not the code. It was Cloudflare declining to issue challenges to a client that had requested dozens of them in a short window. Which is precisely what an anti-bot system is supposed to do to something behaving like a bot.

When an anti-bot system stops cooperating with your automated testing, suspect the tool before the code. Verify from a real browser, on a real network, before concluding anything. We wasted two hours and one unnecessary rollback learning that.

A related trap from the same afternoon: Cloudflare only injects its Web Analytics beacon for requests it scores as genuine browsers. curl never sees it, no matter how you set the User-Agent. We briefly concluded our analytics had never been switched on, when it had been collecting for a month.

How to check your own site

Open any page with a Turnstile widget and run this in the console:

[...document.querySelectorAll('.cf-turnstile')].map(el => ({
  hasIframe: !!el.querySelector('iframe'),
  tokenLength: (el.querySelector('input[name="cf-turnstile-response"]') || {}).value?.length || 0,
  insideClosedDetails: !!el.closest('details:not([open])'),
}));

hasIframe: false with a non-zero-length token is fine — it may still be solving. hasIframe: false and tokenLength: 0 after several seconds means the widget is not rendering, and insideClosedDetails: true tells you why.

Do it on production, in a normal browser window. Not on localhost, and not in a headless one.

Or run the check we built after this, which looks for the same thing from the outside — plus the two related failures it sits next to:

npx leadcheck https://your-site.com/contact

It is open source and has no dependencies: github.com/EBM-corp/leadcheck.


We build and run software for businesses, which means we also run into things like this. If something in your stack is failing in a way nobody can explain, we are happy to look at it — the first conversation is free.

Stay Connected

What's New at EBM newsletter subscription

Our newsletter isn't live yet — when it is, you'll find an unsubscribe link in every email. Refer to our Privacy Statement for more information.