Cloudflare Turnstile

Anti-robot protection for form pages, whole applications and expensive actions.

Useful if you want to…

  • stop bots from submitting a public form without changing the application;

  • protect an application that has no authentication and no form (a public tool, an API-driven frontend) — the page or only its expensive action;

  • add the same check to several pages and hosts from one place;

  • keep the widget working on a page Gate One already proxies.

The block is platform-wide: one configuration entry, every matching page gets the widget or the challenge, every matching request is checked with Cloudflare Siteverify before the backend.

Three modes

ModeProtectsMechanism

forms (default)

form submissions

widget injected into the matching forms; every matching POST is verified

gate

whole pages or actions (paths)

a challenge at the original URL, then a signed 30-minute clearance cookie; later requests pass without a new Siteverify call

both

gate + form submissions

the gate applies, and form POSTs are verified too

gate is the right choice for an application with no form: there is no POST to verify, and the page itself must be protected.

Protecting a whole page

{
  "turnstile": {
    "mode": "gate",
    "siteKey": "0x4AAAAAAA...",
    "secretEnv": "TURNSTILE_SECRET",
    "hosts": ["gram.example.com"],
    "paths": ["/*"],
    "exemptPaths": ["/mcp", "/openapi.json"],
    "clearanceTtlSeconds": 1800,
    "challenge": { "appearance": "interaction-only" }
  },
  "routes": [
    {
      "host": "gram.example.com", "path": "/*", "frontend": true,
      "backend": "http://gram:8000"
    }
  ]
}
  1. GET / without a clearance is answered by a challenge page at the same URL (no redirect). The backend is not called.

  2. The widget solves the challenge and posts the token to /__gate-one/turnstile/verify. With a Non-Interactive widget, interaction-only keeps it hidden (silent verification); set challenge.appearance: "always" to show its loading spinner.

  3. Gate One verifies it, sets gate_one_turnstile (HttpOnly, 30 min) and redirects to /.

  4. The page is served; the backend was called once.

Protecting only the action

To keep the pages public (and indexable) and protect only the expensive endpoint:

"turnstile": {
  "mode": "gate",
  "paths": ["/api/*"],
  "exemptPaths": ["/mcp"],
  "preclearance": true
}

The page loads normally; the injected script pre-clears in the background. An API call without clearance gets 403 {"error":"turnstile_required"} with X-Gate-One-Turnstile: required; the script opens the challenge, exchanges the token and replays the request. The backend sees one call: the rejected request never reached it.

Forms

"turnstile": {
  "mode": "forms",
  "siteKey": "0x4AAAAAAA...",
  "secretEnv": "TURNSTILE_SECRET",
  "paths": ["/contact/*"],
  "forms": ["form#contact"],
  "action": "contact"
}

The widget is rendered in the matching POST forms and the POST is verified before the backend. data-gtw-turnstile="off" opts a form out. both adds this verification to a gate.

Failure modes

SituationStatusBody

Missing/invalid token (forms)

403

readable page or JSON

Missing clearance, navigation (gate)

403

challenge page at the original URL

Missing clearance, fetch/API (gate)

403

turnstile_required + X-Gate-One-Turnstile

Missing clearance, form POST (gate)

403

readable page (data cannot be replayed)

Invalid token at the verify endpoint

403

the challenge page again

Siteverify unreachable

503

turnstile_unavailable, Retry-After: 5

A Siteverify outage is fail-closed, but existing clearance cookies keep working (their signature is checked locally).

CSP

Turnstile needs https://challenges.cloudflare.com in script-src (and script-src-elem when declared) and in frame-src. When a route declares a csp block, Gate One appends those sources automatically in nonce, hash and merge modes. Without a csp block, the page’s own policy (if any) must allow them.

The limitation to document

The check covers only traffic that passes through this Gate One instance:

  • a second ingress (nginx, Traefik, Kubernetes Ingress, another Gate One deployment with its own config) that reaches the application directly bypasses it;

  • internal callers and a published port bypass it.

The invariant is: the application is reachable only through the verification point. Restrict the application’s network access to the Gate One instance and deny everything else.

A gate on / also blocks search-engine crawlers: if the site must stay indexable, gate only /api/.

Limits

  • Tokens are single-use and expire after 300 seconds. Gate One consumes them; the backend must not call Siteverify again.

  • The clearance cookie lasts clearanceTtlSeconds (30 min by default); rotating the signing key invalidates every clearance.

  • The client IP is sent to Cloudflare for validation; mention it in the privacy notice.

  • Cloudflare’s site key is bound to a domain list: every public hostname serving the widget must be declared there.

Try it

The source repository ships a runnable demo on *.localhost with Cloudflare’s test keys:

cd deploy/local/turnstile
./run.sh gate   # whole page gated
./run.sh api    # only the action gated