turnstile-spin: guide token reset after submit to fix V2 double-redemption#83
Open
juleslemee wants to merge 8 commits into
Open
turnstile-spin: guide token reset after submit to fix V2 double-redemption#83juleslemee wants to merge 8 commits into
juleslemee wants to merge 8 commits into
Conversation
…ption Turnstile tokens are single-use. When siteverify returns success:false and the user retries, the browser resubmits the same cf-turnstile-response and Cloudflare's edge rejects it with timeout-or-duplicate. V2 telemetry since the 2026-07-14 launch shows 4.86% sv_fail rate, of which 99% (123/124) are double redemptions — 5x the V1 baseline of 0.9%. Add reset-on-submit guidance to the canonical frontend snippet in SKILL.md and to each framework reference so the widget re-solves before the retry. Also add a 'Token lifecycle' paragraph explaining the single-use contract. - SKILL.md: id=cf-form + reset script; Token lifecycle paragraph - vanilla-html: main HTML + AJAX variant get reset calls - nextjs-app: handleSubmit resets on !data.ok; Server Action variant covers useActionState error branch - nextjs-pages: reset script wrapped in <Script> for hydration ordering - sveltekit: use:enhance callback resets on non-redirect + client-fetch variant - astro: main form script + Astro Actions variant reset - hugo: partial gets reset script
juleslemee
pushed a commit
to juleslemee/cloudflare-docs
that referenced
this pull request
Jul 17, 2026
…r V2 Combines two changes in the docs mirror of the Spin skill: 1. Sync prompt.md to the current cloudflare/skills SKILL.md (em-dash cleanup, 'Do not propose features' bullet added to Things you must NOT do, recovery flow line 4 wording updated to match SKILL.md, redundant backend snippet dropped since it now lives only in Step 9). 2. Token-reset hotfix. Turnstile tokens are single-use; when siteverify returns success:false and the user retries, the browser resubmits the same cf-turnstile-response and Cloudflare's edge rejects it as timeout-or-duplicate. V2 telemetry since the 2026-07-14 launch shows 4.86% sv_fail rate, 99% of which are double redemptions (5x V1). The canonical HTML snippet in 'The frontend-edit contract' now gets id=cf-form + a setTimeout reset-on-submit listener, plus a new 'Token lifecycle' paragraph explaining the single-use contract. Companion PRs: - cloudflare/skills#83 (canonical SKILL.md + 6 framework references) - stratus (spinSkillContent.ts template-literal mirror)
juleslemee
marked this pull request as ready for review
July 17, 2026 21:33
juleslemee
requested review from
dmmulroy,
elithrar,
irvinebroque and
thomasgauvin
as code owners
July 17, 2026 21:33
Contributor
Author
|
cc @marinaelmore — this is the canonical-skill half of the Spin V2 token-reset hotfix. Companion PRs where you're already auto-added as reviewer:
Ready for review; hoping to merge Monday AM. |
added 2 commits
July 21, 2026 14:58
Mirrors the fixes applied in cloudflare/cloudflare-docs#32147: * Siteverify snippet in Step 9 now wraps fetch+json in try/catch, checks r.ok, and replaces the non-existent reject(403, ...) helper with res.status(403).send('forbidden') plus a comment about adapting to the target framework. * Recovery flow step 3 no longer offers 'ask user or exit' for non-no_clearance widgets, which contradicted the scope boundary. Always exits per the boundary now. Keeps this file byte-for-byte in sync with the docs prompt.md and stratus spinSkillContent.ts (the three-surface parity that the Spin machinery depends on).
Marina flagged on cloudflare/cloudflare-docs#32147 that the skill's scope reads too form-centric, when Turnstile actually applies to any user-triggered request the customer wants to gate: form submissions, SPA button-triggered API calls, download links, comment or vote submissions, and any other explicit user action that hits a backend the customer controls. Broadened six spots to match how Turnstile is actually used in the wild (per the product docs at developers.cloudflare.com/turnstile), while keeping the canonical form example in the frontend-edit contract section as the primary illustration: * Description: 'embed it on the right forms' -> 'embed it where user requests need bot verification (form submissions, SPA actions, API endpoints, download links, comment or vote submissions, etc.)'. * When-to-load triggers: added 'protect this endpoint', 'protect this button', 'block bots on <target>'; the 'specific request' example bullet now lists downloads / comments / API endpoints alongside signup / login / contact form. * Brief-acknowledge line the agent says on Step 1. * Wire-the-integration statement the agent says on Step 9. * Hard-scope-boundary sentence: dropped 'form' qualifier on handler. * Frontend-edit-contract opener: covers form or user-triggered endpoint; dropped 'submit' from handler. Kept in sync across all three surfaces (this canonical SKILL.md, prompt.md docs, stratus spinSkillContent.ts).
juleslemee
pushed a commit
to juleslemee/cloudflare-docs
that referenced
this pull request
Jul 21, 2026
Marina flagged on this PR that the skill's scope reads too form-centric, when Turnstile actually applies to any user-triggered request the customer wants to gate: form submissions, SPA button-triggered API calls, download links, comment or vote submissions, and any other explicit user action that hits a backend the customer controls. Broadened six spots to match how Turnstile is actually used in the wild (per the product docs at developers.cloudflare.com/turnstile), while keeping the canonical form example in the frontend-edit contract section as the primary illustration: * Description: 'embed it on the right forms' -> 'embed it where user requests need bot verification (form submissions, SPA actions, API endpoints, download links, comment or vote submissions, etc.)'. * When-to-load triggers: added 'protect this endpoint', 'protect this button', 'block bots on <target>'; the 'specific request' example bullet now lists downloads / comments / API endpoints alongside signup / login / contact form. * Brief-acknowledge line the agent says on Step 1. * Wire-the-integration statement the agent says on Step 9. * Hard-scope-boundary sentence: dropped 'form' qualifier on handler. * Frontend-edit-contract opener: covers form or user-triggered endpoint; dropped 'submit' from handler. Kept in sync across all three surfaces (this prompt.md, cloudflare/skills SKILL.md, stratus spinSkillContent.ts). Companion commits: - cloudflare/skills#83: 8192e1d - stratus MR !40817: 399fbbbd1e0
Replaces hand-escaped JSON with python3 json.dumps for both request
bodies and response outputs, adds argument-value validation before
each shift, structures HTTP error handling, and switches auth-probe
from a GET-based read probe to a POST-invalid-payload probe so it
tests Turnstile:Edit specifically.
widget-create.sh:
- python3 json.dumps for request body + success/error output
(replaces `echo "{\"name\":\"$NAME\",...}"` which broke
on names containing quotes, backslashes, or control characters)
- Argument value validation before shift
- Remove `2>/dev/null` on the API curl so network errors reach stderr
- URL-encode account_id
validate.sh:
- Argument value validation
- mktemp + -o + %{http_code} pattern for the widget-domains lookup
- python3 with structured error handling for JSON parse failures
- URL-encode account_id and sitekey
auth-probe.sh:
- `command -v wrangler` guard before invocation (avoids npx
install-prompt hang in non-interactive envs); fall back to
$CLOUDFLARE_ACCOUNT_ID when wrangler is not on PATH
- Replace GET-based scope probe with POST-invalid-payload probe:
a GET would authorize a Read-only token and let the agent proceed
to widget-create where it would fail. POST-invalid tests Edit
specifically: 401/403/err-10000 -> missing_scope, 400/422 -> scope OK.
- mktemp with explicit template for macOS/BSD portability
- Argument value validation
fetch-secret.sh:
- Argument value validation
- mktemp with explicit template + trap-based cleanup on early exit
- Python fallbacks use .get() with structured error output on
missing keys or non-JSON responses
- URL-encode account_id and sitekey
persist-skill.sh:
- Argument value validation
- Build scripts JSON list via python3 os.listdir + json.dumps
(replaces ls | sed | paste which mishandled filenames with quotes,
backslashes, or newlines)
Flagged by cloudflare-docs-bot on
cloudflare/cloudflare-docs#32147: 1 critical
(CR-22ef8f6771b1) + 15 warnings + 1 suggestion. All 19 addressed.
Companion: cloudflare-docs #32147, stratus MR !40817.
juleslemee
pushed a commit
to juleslemee/cloudflare-docs
that referenced
this pull request
Jul 21, 2026
Address all 19 findings from cloudflare-docs-bot on this PR: 1
critical + 15 warnings + 1 suggestion + 1 convention (PR description
scope, updated separately).
widget-create.sh:
- CR-22ef8f6771b1 (critical): build request body via python3
json.dumps instead of hand-escaping quotes into a raw JSON string
- CR-f735353f7ad5: argument value validation before shift
- CR-e48c7d0bb1e9: drop `2>/dev/null` on the API curl so network
errors reach stderr
- CR-a9681e1b8608, CR-5fd7af1e876a: build success and error output
via python3 json.dumps
validate.sh:
- CR-e781359b7c28: argument value validation
- CR-afe9e2b7b7c0: mktemp + %{http_code} pattern with explicit
status check for the widget-domains lookup
- CR-391c70408be0: python3 parse with structured error handling
auth-probe.sh:
- CR-1f8cc5f1475c: `command -v wrangler` guard before invocation,
fall back to $CLOUDFLARE_ACCOUNT_ID when wrangler is unavailable
- CR-c5afb0714a2f: replace GET-based scope probe with
POST-invalid-payload probe to test Turnstile:Edit specifically
fetch-secret.sh:
- CR-e82849253dd7: argument value validation
- CR-30730b7afe35: mktemp with explicit template for macOS/BSD
- CR-1bd6e05a63d9 (suggestion): trap-based temp cleanup
- CR-2a35782321aa: Python .get() fallbacks with structured errors
- CR-76a6232130e9: URL-encode account_id and sitekey
persist-skill.sh:
- CR-03ee17a69501: argument value validation
- CR-bb0ae4241b8d: build scripts JSON list via python3
os.listdir + json.dumps
All scripts pass 'bash -n' and end-to-end smoke tests against a
non-existent account (structured error output, no tracebacks). Same
5 scripts committed to cloudflare/skills at
cloudflare/skills#83 and inlined into
stratus MR !40817.
Second round of docs-bot findings: 6 real issues + 3 secrets-in-argv warnings addressed. The bot's critical (EXIT trap fires in $() subshell) is a false positive verified with a bash test: subshells reset their parent's traps per the bash manual, so $tmp survives curl $() calls and the trap only fires on outer-script exit. - All scripts: 'command -v python3' guard at top; emit structured JSON error on missing prerequisite instead of raw shell error. - All scripts: header comments now document exit 2 (invalid usage) in addition to exit 0 (success) and exit 1 (failure). - widget-create.sh, validate.sh, fetch-secret.sh, auth-probe.sh: Authorization header written to mode-600 tempfile, curl reads it via '-H @file'. Bearer token never appears in argv. - validate.sh: Turnstile secret written to mode-600 tempfile, curl reads it via --data-urlencode 'secret@file'. Secret never appears in argv. - validate.sh: strip whitespace from each expected-domains token so '--expected-domains "a.com, b.com"' matches correctly. - fetch-secret.sh, auth-probe.sh: guard 'errors[0]' with '(errors[0] or {})' before .get() to survive non-dict error entries. - All parse blocks: 'isinstance(data, dict)' check after json.loads; emit structured error output when response is not a JSON object. - persist-skill.sh: catch OSError (not just FileNotFoundError) so NotADirectoryError/PermissionError on the scripts dir emit 'scripts: []' instead of a traceback. Skipped: - CR-04913c72363f (critical, false positive; bash test in the docs PR triage comment) - CR-14b4e5e6a596 (suggestion; --path is user-supplied by design so agents can persist to .claude/skills, .opencode/skills, etc.) Companion: cloudflare-docs #32147, stratus MR !40817.
juleslemee
pushed a commit
to juleslemee/cloudflare-docs
that referenced
this pull request
Jul 21, 2026
Second round of docs-bot findings on this PR: 6 real issues + 3
secrets-in-argv warnings addressed. The bot's critical CR-04913c72363f
(EXIT trap fires in $() subshell) is a false positive; see triage
comment on this PR for the bash-test disproof. CR-14b4e5e6a596
(unvalidated --path) is skipped with reasoning in the same comment.
- CR-8564af26b1eb: strip whitespace from expected-domains tokens
- CR-244ecc0a2bae: (errors[0] or {}).get() defensive guard
- CR-170e734f63cc: isinstance(data, dict) after json.loads
- CR-9cdbd7ec5a36: 'command -v python3' guard at top of each script
- CR-460039bb0738: document exit 2 for invalid usage in headers
- CR-3b31dbc5640a: catch OSError instead of just FileNotFoundError
- CR-4a512fac7815, CR-333053976387, CR-f14858b663da: Bearer token and
Turnstile secret now go through mode-600 tempfiles (curl -H @file /
--data-urlencode name@file); never in argv.
Same 9 fixes propagated to cloudflare/skills#83 and stratus MR !40817.
Docs-bot third-round review flagged four items: - Unchecked mktemp in widget-create.sh (two calls). Applied inline mktemp guards across all mktemp calls in widget-create.sh, validate.sh, fetch-secret.sh, and auth-probe.sh. On failure the scripts emit structured error JSON matching the existing contract and clean up any earlier tempfiles before exit. - fetch-secret.sh accessed errors[0] without first checking that errors is a list. Added isinstance(errors, list) guard and applied the same fix to widget-create.sh, validate.sh, and auth-probe.sh which have the same pattern. - persist-skill.sh had a python3 command -v guard but not one for npx (needed for the degit call). Added the npx guard next to it, returning a structured error JSON on absence.
Contributor
Author
|
@marinaelmore GitHub won't let me add you as a formal reviewer here (fork PR, non-codeowner restriction), but you can submit an approve directly from the Files tab without being requested. This PR is the canonical skills half of the same three-repo Turnstile Spin V2 change you're already reviewing in cloudflare/cloudflare-docs#32147. |
added 2 commits
July 22, 2026 13:30
Docs-bot and gsa_claude flagged that the Edit-scope probe POSTs a widget-create request with intentionally-invalid empty name/domains to distinguish Read scope (401/403) from Edit scope (400/422). The API rejects the payload today, so no widget is created, but there was no safety net if validation ever loosened. Parse result.sitekey from the probe response and, if a widget was unexpectedly created (success:true with a sitekey), fire a DELETE against it so the probe stays side-effect-free regardless of future API validation changes. Verdict logic and existing 400/422/200/401/ 403 branches are unchanged.
gsa_claude flagged that Step 2's CLI-check paragraph still said Spin's helper scripts use `npx wrangler whoami` for account enumeration and that no persistent CLI install is required. Neither is accurate after the auth-probe.sh rewrite: the probe uses PATH-resident `wrangler` (not npx, so a non-interactive shell won't hang on auto-install) and falls back to requiring `$CLOUDFLARE_ACCOUNT_ID` when wrangler is missing. Rewrite line 38 (Step 2) and line 195 (`wrangler whoami` troubleshooting row) to reflect the actual behavior: PATH check first, otherwise require the account ID be exported. Also drop the misleading suggestion to curl `/accounts` as a fallback \x2D the Account.Turnstile:Edit token can't list accounts (`/accounts` is Account.Read-scoped), so that fallback would 403.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
The Spin V2 dashboard flow went to public production on 2026-07-14 (widget
Set up with Spinbutton). The first 3 days of telemetry showed elevatedtimeout-or-duplicatefailures on the canonical siteverify path:Zero
invalid-input-secret, oneother_failure. Secret wiring is fine; the failure class is stale-token retries.Root cause
cf-turnstile-responsetokens are single-use. When siteverify returnssuccess: false, the customer's error UI puts the form back with the same DOM-cached token; a naive retry submits it and the edge rejects the second attempt astimeout-or-duplicate.The V1 skill flow deployed a managed Worker that owned the whole request path, including client-side widget lifecycle. When V2 pivoted to canonical siteverify inside the customer's own backend, the client-side reset guidance was dropped in the simplification. Grep for
reset,single-use,timeout-or-duplicate,data-error-callbackacross SKILL.md + all six framework references before this PR: zero hits.The needle-mover here shipped on the server side: siteverify release
2026.7.3(tagged 2026-07-20) cut V2's error rate from 16.4% at Sunday's traffic peak to 2.35% today on 51K events. V1 is running at 4.5% today over the same window, so V2 is now comfortably below V1.This PR's client-side changes remain worth landing as hygiene: without them, agent-generated customer code keeps producing the redundant-siteverify pattern that 2026.7.3 has to absorb server-side. With them, agents emit the correct client-side contract from the start.
Fix
Client-side reset guidance in three places:
The frontend-edit contractsection) gets anid="cf-form"and asetTimeout(() => window.turnstile?.reset(), 0)submit listener.elsebranch ofhandleSubmitfor AJAX,use:enhancecallback for SvelteKit form actions,useActionStateuseEffectfor Next.js Server Actions.The reset-on-submit pattern (as opposed to
data-error-callbackwiring) was chosen because it's the smallest diff, framework-agnostic, and requires no callback wiring. The trade-off is one extra Turnstile challenge on successful in-page-retry submits (which is fine — Turnstile challenges are free and typically invisible).Helper-script hardening
skills/turnstile-spin/scripts/also picks up a robustness pass in response to the docs-bot's first review on the docs mirror. Findings addressed: 1 critical + 15 warnings + 1 suggestion.widget-create.sh: build request body and success/error output viapython3 json.dumps(the originalcurl -d "{\"name\":\"$NAME\",...}"broke on names containing quotes, backslashes, or control characters). Argument-value validation before everyshift 2. Drop2>/dev/nullon the API curl so network errors reach stderr. URL-encodeaccount_id.validate.sh: switch tomktemp+-o $tmp+-w %{http_code}pattern; explicit HTTP status check before parsing; structured Python error handling for JSON parse failures. URL-encodeaccount_idandsitekey.auth-probe.sh:command -v wranglerguard before invocation (avoids npx install-prompt hang in non-interactive envs) with fallback to$CLOUDFLARE_ACCOUNT_ID. Replace GET-based scope probe with POST-invalid-payload probe so it tests Turnstile:Edit specifically (a GET would authorize a Read-only token and let the agent proceed towidget-create.shbefore failing).fetch-secret.sh: argument validation,mktempwith explicit template + trap-based cleanup, Python fallbacks use.get()with structured error output, URL encoding.persist-skill.sh: build the scripts JSON list viapython3os.listdir + json.dumps (replacesls | sed | pastewhich mishandled filenames with quotes, backslashes, or newlines).All 5 scripts pass
bash -nand end-to-end smoke tests.Companion PRs
The three surfaces of the Spin skill must stay in lockstep (canonical SKILL.md, docs prompt.md, dashboard-inlined
spinSkillContent.ts):spin-docs-followupbranch, syncs prompt.md and the 5 helper scriptsspinSkillContent.tsRollout
Draft opened Friday 2026-07-17. With siteverify 2026.7.3 already shipping the server-side fix on Monday and V2 running comfortably below V1 (2.35% vs 4.5% today), this PR can land at normal review speed as product-hygiene rather than a hotfix. Grafana dashboard
dfscir1b3r4sgcis the launch monitor.Follow-up
The three-surface manual sync is manual by design (see the header comment at the top of
spinSkillContent.tsin stratus). NC-8619 tracks adding runtime-fetch-with-fallback in stratus so the dashboard picks up prompt.md changes without a stratus MR.