CSP for Cloudflare Turnstile

script-src https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com

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, so frame-src https://challenges.cloudflare.com is required in both variants. Without it the browser refuses the challenge frame.
  • Pre-clearance needs 'self' in connect-src. In pre-clearance mode Turnstile sets the cf_clearance cookie by fetching an endpoint under /cdn-cgi/ on your own domain. A connect-src that 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-Only with a report-to endpoint 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;
}

Try it in the CSP Builder

everything that is fetched and has no directive of its own
scripts, inline and loaded
frames and iframes
fetch(), XMLHttpRequest, WebSocket and EventSource
<object>, <embed> and <applet>
the URLs a <base> element may point at
who may frame this page

A nonce has to be fresh for every response, so 'nonce-REPLACE_ME' is a placeholder: your server fills it in and puts the same value in the <script nonce=…> tag.

1 finding worst low

7 directives

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'

The same policy as a meta tag

<meta http-equiv="Content-Security-Policy" content="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'">

A meta tag only works in HTML, has to be in the <head>, and is read once: a header can be changed per response.

Findings · worst low

  • low A <meta> tag ignores frame-ancestors. If this policy goes into HTML, that directive is doing nothing there: send it as a header.

Every directive, in plain words

DirectiveWhat it governsSources
default-srceverything that is fetched and has no directive of its own
  • 'self' this origin only
script-src
→ default-src
scripts, inline and loaded
  • 'nonce-REPLACE_ME' the script carrying this nonce may run
  • 'strict-dynamic' only scripts this document trusts may add more scripts
  • https://challenges.cloudflare.com the host challenges.cloudflare.com over https, but scripts ignore it: 'strict-dynamic' is listed
frame-src
→ child-src → default-src
frames and iframes
  • https://challenges.cloudflare.com the host challenges.cloudflare.com over https
connect-src
→ default-src
fetch(), XMLHttpRequest, WebSocket and EventSource
  • 'self' this origin only
object-src
→ default-src
<object>, <embed> and <applet>
  • 'none' nothing of this kind may load
base-urithe URLs a <base> element may point at
  • 'none' nothing of this kind may load
frame-ancestorswho may frame this page
  • 'self' this origin only

Checked against CSP Level 3: every directive, every source keyword, and the fallback chain that carries a directive to default-src. Nothing is uploaded, and no request is made to the site whose policy you paste.

Runs in your browser. Nothing you enter is sent anywhere.