Files
dotfiles/dot_claude/skills/turnstile-spin/templates/worker
Prad NukalaandClaude Fable 5 20891f3910 config(git,claude,ssh): templated gitconfig; claude settings/commands/hooks/skills; ssh config
gitconfig: name/email from chezmoi data, gh credential helper by PATH
lookup (portable to darwin). claude: no credentials, history, projects,
sessions, or plugin caches. ssh config as private_ (0600).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:53:46 -04:00
..

turnstile-siteverify (Worker template)

The managed siteverify Worker template that ships inside the Turnstile Spin skill. The Spin agent deploys this template into the user's Cloudflare account as the backend for siteverify token validation.

You are reading this either because the Spin skill copied it into your project directory, or because you navigated to cloudflare/skills/skills/turnstile-spin/templates/worker/ directly.

Manual deploy

If you have this directory on disk and want to deploy it yourself:

npm install

# Set your Turnstile secret as a Worker secret (never commit this).
npx wrangler secret put TURNSTILE_SECRET_KEY

# Deploy.
npx wrangler deploy

Wrangler prints the deployed URL. Point your frontend's form action (or fetch target) at it. Confirm the integration with a curl:

curl -sf https://<your-worker-url>/health
# {"ok":true,"version":"1.0.0"}

Endpoints

Method Path Purpose
POST / Siteverify proxy. Accepts JSON or form-encoded body.
POST /siteverify Alias of POST /.
GET /health Health check. Returns {"ok": true, "version": "..."}.
GET / Same as /health.

Request

JSON body:

{
	"token": "TURNSTILE_TOKEN_FROM_WIDGET",
	"remoteip": "1.2.3.4",
	"idempotency_key": "optional-uuid"
}

Form-encoded body:

token=...&remoteip=1.2.3.4

cf-turnstile-response is accepted as an alias for token in both body types, so a <form> posting directly to the Worker works without any client-side JavaScript.

Response

{
	"success": true,
	"challenge_ts": "2026-05-29T12:00:00Z",
	"hostname": "example.com",
	"action": "turnstile-spin-v1",
	"error-codes": [],
	"_worker": {
		"duration_ms": 87,
		"worker_version": "1.0.0"
	}
}

On validation failure, success is false and error-codes lists the reasons. The HTTP status is 200 regardless of validation outcome (callers should check response.success). On Worker-level failures, the HTTP status reflects the error class (400 for bad input, 415 for unsupported content type, 500 for missing secret, 502/504 for upstream issues).

Error codes

Code Meaning
missing-input-secret TURNSTILE_SECRET_KEY not set
invalid-input-secret Secret rejected by upstream
missing-token No token in the request body
invalid-input-response Token rejected by upstream
timeout-or-duplicate Token expired (>5min) or already validated
invalid-content-type Body content-type was not JSON or form-encoded
upstream-unreachable Could not reach challenges.cloudflare.com
upstream-timeout Upstream took longer than 5 seconds
hostname-mismatch EXPECTED_HOSTNAME is set and the response hostname did not match
bad-request Generic catch-all for malformed input
internal-error Unhandled error in the Worker

Configuration

Variable Type Default Purpose
TURNSTILE_SECRET_KEY secret (required) Widget secret. Set via wrangler secret put TURNSTILE_SECRET_KEY.
ALLOWED_ORIGIN var * CORS allowed origin. Lock down to your customer-facing domain.
EXPECTED_HOSTNAME var (unset) If set, reject siteverify responses where hostname differs.
LOG_LEVEL var info One of debug, info, warn, error. Filters structured logs.

Set vars in wrangler.toml ([vars] block) or via wrangler deploy --var KEY:value. Secrets only via wrangler secret put.

Frontend snippet

The minimum HTML that works against this Worker:

<script
	src="https://challenges.cloudflare.com/turnstile/v0/api.js"
	async
	defer
></script>

<form action="https://YOUR_WORKER_URL/" method="POST">
	<input name="email" type="email" required />
	<div
		class="cf-turnstile"
		data-sitekey="YOUR_SITEKEY"
		data-action="turnstile-spin-v1"
	></div>
	<button type="submit">Submit</button>
</form>

The data-action="turnstile-spin-v1" attribute is the Spin telemetry marker. Cloudflare uses it to measure activation rates for Spin-deployed widgets. It is account-level and aggregate; no PII. See the docs page.

For React, SvelteKit, Astro, Hugo, and other framework examples, see developers.cloudflare.com/turnstile/spin.

Production hardening

After the first deploy, before pointing real traffic:

Step Why
Set ALLOWED_ORIGIN to your real domain in wrangler.toml Locks CORS so other sites cannot proxy through your Worker
Set EXPECTED_HOSTNAME if your widget is single-domain Defends against cross-site token replay
Configure Workers Observability alerts on error rate > 1% Catches upstream outages or secret-misconfig events
Rotate TURNSTILE_SECRET_KEY periodically Standard secret hygiene
Add a Workers rate limit if abuse warrants Defends the Worker itself

Testing

npm test                   # unit tests, mocks fetch
npm run test:integration   # hits real siteverify with test secrets
npm run typecheck
npm run openapi:lint

Cloudflare's documented test secrets:

Secret Behavior
1x0000000000000000000000000000000AA Always succeeds
2x0000000000000000000000000000000AA Always fails
3x0000000000000000000000000000000AA Returns timeout-or-duplicate

Project layout

src/
├── index.ts          # Fetch handler, routing, CORS, body parsing
├── validate.ts       # siteverify call with retry + timeout
├── observability.ts  # Structured log emission
├── errors.ts         # SiteverifyError + response shaping
└── types.ts          # Request/response types, error codes
test/
├── unit.test.ts          # Mocked siteverify
├── integration.test.ts   # Real network, test secrets
├── deploy.test.ts        # Deploy-to-clean-account speed assertion
└── validation.test.ts    # Validation surface (health, hostname, structured errors)
public/
└── post-deploy.html  # Post-deploy form for the Deploy button path
openapi.yaml          # OpenAPI 3.1 spec
wrangler.toml         # Worker config

Contributing

Two rules:

  1. Keep the request/response contract stable. Customers will deploy this Worker as-is.
  2. Do not add features that require additional secrets or external services. The point is "drop in, set one secret, you are done."

PRs welcome.

License

MIT. See LICENSE.