@codefusion-cc/turnstile
v0.1.0
Published
Cloudflare Turnstile for a form: the server check (Siteverify as a result, with a timeout, one retry under an idempotency key, hostname and action checks, no fail-open), the page's widget (script loader, one fresh token per submit, blocked and expired sta
Maintainers
Readme
@codefusion-cc/turnstile
Cloudflare Turnstile for a form: the check on the server, the widget in the page, and a fake Cloudflare for tests.
.(Workers, WebCrypto andfetchonly):verifyTurnstile, the Siteverify call as a result, andisTestingKey../browser: the script loader andcreateTurnstile, a controller that renders the widget and hands out one fresh token per submit../react:useTurnstile, the same as a hook../testing:fakeSiteverify, Cloudflare's published test keys.
No dependencies; React is an optional peer dependency of ./react.
npm install @codefusion-cc/turnstileOn the server
import { verifyTurnstile } from '@codefusion-cc/turnstile'
const check = await verifyTurnstile({
secret: env.TURNSTILE_SECRET_KEY,
token: body.turnstile, // whatever the page sent, even if it is not a string
remoteIp: request.headers.get('cf-connecting-ip'),
hostnames: ['shop.example', 'www.shop.example'],
action: 'checkout',
})
if (!check.ok) return refuse(check.reason) // before anything is written or countedIt never throws for what happens at run time and never lets a form through that it could not check:
| reason | Meaning | Tell the visitor |
| --- | --- | --- |
| missing, too_long | no token, or one over 2048 characters (no call is made) | the check did not finish; try again |
| rejected | Cloudflare does not know the token | the check failed; try again |
| used_or_expired | spent before, or older than five minutes (Cloudflare cannot tell which) | the check ran out; try again |
| hostname, action | a real token from another site, or for another form | the check failed |
| testing_key | the answer, a yes or a no, came from a published test key (refused unless allowTestingKey) | nothing; log it |
| misconfigured | no secret, no hostnames, no action, or Cloudflare refuses the secret | nothing; log it, it is yours to fix |
| unavailable | no usable answer from Cloudflare after one retry | the shop cannot check right now; try again |
- Fail closed.
unavailableandmisconfiguredare refusals. Whether to let an order through while Cloudflare is down is the caller's decision, and the default is no; a shop with other limits behind the check should still weigh that those limits are the weaker net. - Timeout and retry. One attempt waits
timeoutMs(3000 ms), a failed attempt is asked again once (retries). Network errors, no answer, HTTP 5xx and 429, a redirect (never followed), a body that is not Siteverify's andinternal-errorcount as no answer. - Idempotency. Every attempt of one call carries the same
idempotency_key, so a token Cloudflare consumed on an attempt whose answer was lost is not refused as a duplicate on the retry. - Hostname and action must match. Without them a token made on any site with any widget would pass.
- Single use. Cloudflare accepts a token once; asking again is
used_or_expired. - Test keys. Cloudflare's test secrets answer any token, with hostname
example.com, no action, andmetadata.result_with_testing_key. By default such an answer is refused (testing_key), so a test secret left in production opens nothing; passallowTestingKey: truein development and tests only.isTestingKey(key)tells a published test key (site key or secret) from a real one without a call, for a health check that reports the form as not set up before the first visitor meets it. - The secret is sent without the blanks around it (a pasted secret often ends with a line end, which Cloudflare refuses).
The result and the log line (detail) hold neither the token nor the secret.
In the page
import { useTurnstile } from '@codefusion-cc/turnstile/react'
const check = useTurnstile(siteKey ? { siteKey, action: 'checkout', language: 'pl', theme: 'dark' } : null)
<div ref={check.ref} /> // the widget draws here; give it the room you want to keep
async function submit() {
const got = await check.take() // waits for a running check, then hands out the token once
if (!got.ok) return explain(got.reason) // 'blocked' | 'failed' | 'timeout' | 'destroyed'
await send({ ...form, turnstile: got.token })
}check.snapshot.phase:loading,working,interactive(Cloudflare shows a box the visitor must use),ready,failed(Cloudflare reportserrorCodeand retries),blocked(the script did not load: an ad blocker, a firewall, no network; the page retries when the browser is back online,check.retry()does it on demand, and so does the nexttake(), which waits for that load),off.- Default
appearance: 'interaction-only': most visitors see nothing. The element has no height of its own to keep; the page decides what to show in each phase. - One token per submit.
take()hands out the token and starts the next check at once, so a retry after a refused submit already has a fresh one. - An expired token is replaced by Cloudflare by itself (
refresh-expired). size: 'auto':flexiblewhen the element is at least 300 px wide, otherwisecompact(150 px), so a 320 px phone does not scroll sideways. The width is read when the widget is drawn, not when the window changes later.- A new
themeorlanguagekeeps a token already held and shows in the next check; a check in progress starts over with them. interactivecan come before the visitor submits. Show the box then, but move the page to it only while atake()is waiting.
The page's Content-Security-Policy needs https://challenges.cloudflare.com in script-src and frame-src, and
nothing in connect-src: the widget talks to Cloudflare from its own frame.
Tests
import { fakeSiteverify, TEST_SECRET_KEYS, TEST_SITE_KEYS } from '@codefusion-cc/turnstile/testing'
const cloudflare = fakeSiteverify({ claims: { hostname: 'shop.example', action: 'checkout' } })
const token = cloudflare.issue()
cloudflare.fail('hang', 2) // also network, http500, http429, not-json, internal-error, loseThe fake knows the tokens it issued (single use, the idempotency key, issueExpired), answers only Cloudflare's
address, and records every request. It answers TEST_SECRET_KEYS as the live Siteverify does (any token, none spent,
the test-key mark), so a test can prove that a real site refuses them. TEST_SITE_KEYS and TEST_SECRET_KEYS are
Cloudflare's published keys for local runs: pass, block, and a widget that forces the visible box (interactive)
or a secret that answers timeout-or-duplicate (spent); they are the only keys a repository may hold.
