Cloudflare Turnstile needs script-src https://challenges.cloudflare.com and frame-src https://challenges.cloudflare.com: the script draws the widget, and the challenge runs in an iframe from the same host. Cloudflare’s Turnstile CSP reference gives exactly those two lines, and recommends a nonce-based policy over the allowlist.
The allowlist
Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'
With a nonce, as Cloudflare recommends
Content-Security-Policy: default-src 'self'; script-src 'nonce-REPLACE_ME' 'strict-dynamic' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'
Put the nonce on the api.js tag, and Turnstile passes it on to the resources it loads; Cloudflare says Turnstile works with 'strict-dynamic'. The host stays as the fallback for browsers without 'strict-dynamic'; the builder’s table marks it as ignored wherever 'strict-dynamic' is understood.
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" nonce="REPLACE_ME" async defer></script>
Both policies get one low finding from the builder: a <meta> tag ignores frame-ancestors, so send them as a header. The builder doesn’t know which widgets you embed, though: drop frame-src from the strict one and it rates the same, while the browser now refuses the challenge frame, because frame-src falls back through child-src to default-src 'self'.
Gotchas
'strict-dynamic'doesn’t cover frames. It only changes how scripts are trusted, soframe-src https://challenges.cloudflare.comis required in both variants. Without it the browser refuses the challenge frame.- Pre-clearance needs
'self'inconnect-src. In pre-clearance mode Turnstile sets thecf_clearancecookie by fetching an endpoint under/cdn-cgi/on your own domain. Aconnect-srcthat lists only your API host blocks it. - Server-side validation isn’t the browser’s business. Your backend posts the token to Cloudflare’s siteverify endpoint itself, and CSP never sees that request.
- Start in Report-Only. Send the new policy as
Content-Security-Policy-Report-Onlywith areport-toendpoint and watch for blocked frames before you enforce it.
A fresh nonce in nginx
$request_id is 16 random bytes in hex, new for every request, so it can serve as the nonce. sub_filter (from ngx_http_sub_module) writes it into the page, and only rewrites responses that aren’t compressed:
location / {
proxy_pass http://app;
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter 'nonce="REPLACE_ME"' 'nonce="$request_id"';
add_header Content-Security-Policy "default-src 'self'; script-src 'nonce-$request_id' 'strict-dynamic' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'" always;
}