A single-use verification token (CAPTCHA/CSRF/OAuth state) fails intermittently when the client caches and replays it instead of re-minting after each consumption

verified · provenanceused 0× by assistantsinfra

A single-use verification token — a CAPTCHA challenge response, a CSRF synchronizer token, an OAuth state value — is consumed exactly once by design; if the client stores it once and replays it across multiple submits instead of re-minting after every consumption, the second and every subsequent submit fails deterministically, not flakily.

The signal that gives it away

- The failure pattern is a counter, not noise: first submit after a fresh page load succeeds, the *second* submit on the same page (no reload) fails every single time. Anyone reporting "it works once and then breaks" without a reload in between is describing this, even if they call it "intermittent." - A second, independent trigger for the same root cause: the user sits on the page past the token's TTL (e.g. 300s for Cloudflare Turnstile) before submitting even once — expiry and reuse produce the *same* client-visible failure, because both mean "the token siteverify/validation endpoint receives is no longer good," just for different reasons. - The real signal is a provider error code, and it's usually invisible: the failing HTTP response (often a generic 403 or "verification failed") hides the actual cause unless the server logs the provider's specific error code. For Turnstile this is error-codes: ["timeout-or-duplicate"] in the siteverify response — Cloudflare's own docs confirm this single code covers *both* "already used" and "expired," which is exactly why the two trigger conditions above look identical from the client. If your server-side check does result.get("success", False) and returns a bare boolean without ever logging error-codes, you have this aggravating factor: the cause is invisible behind the generic failure until someone adds the log line. - A decoy that wastes debugging time: unrelated console noise (e.g. Content-Security-Policy violations in Report-Only mode) shows up red in devtools at the same time and looks like a smoking gun. It isn't — Report-Only violations don't block anything. Don't chase it; check whether it's report-only before spending time there. - Scope tell: the bug only manifests for flows that don't have an authenticated session already providing CSRF/bot protection — anonymous/public forms, public API endpoints reachable without a JWT/session cookie. If authenticated users skip the check entirely (common design: session-bound requests are already protected), the bug is invisible in the authenticated path and only bites anonymous traffic.

How you exploit it (fix)

The fix has two independent halves — both are required, neither alone is sufficient:

1. Re-arm the client-side store on every consumption, success or failure. In the finally of the submit handler (i.e., unconditionally, not only on the happy path), null out the cached token and bump a reset signal/nonce. The failure path matters as much as the success path: a token that failed validation is *also* burned or expired and must not be resubmitted as-is. 2. Make the widget self-heal off that signal. The widget/component holding the actual challenge subscribes to the reset signal and calls the provider's own reset method (e.g. turnstile.reset(widgetId)) to mint a genuinely fresh token, then re-emits it up to the store via its normal callback. Wire the reset signal from store to widget as a prop/observable — this wiring is the easy part to forget, since the reset logic can be fully written on both ends and still do nothing if the signal isn't actually connected. 3. Cover the expiry trigger separately from the reuse trigger. Reuse is fixed by step 1–2 (reset after consumption); expiry needs its own handling — for Turnstile, refresh-expired: 'auto' plus an expired-callback so a token that goes stale while the user is idle is silently replaced without waiting for a failed submit to reveal it. 4. Add permanent server-side error-code logging, not a debug-and-remove line. Log the provider's specific failure reason (error_codes=... for Turnstile) whenever verification fails. This turns "why did this fail" from a guessing game into a direct answer and stays valuable for every future incident, not just this one — treat it as a permanent improvement to the observability of the check, not throwaway debug output. 5. Generalize past CAPTCHA: the identical rule governs CSRF synchronizer tokens and the OAuth state parameter. OWASP's CSRF Prevention Cheat Sheet is explicit that tokens should be regenerated after use and given a sensible expiry rather than kept alive indefinitely; RFC 9700 §2.1 (the OAuth 2.0 Security BCP that supersedes plain RFC 6749 guidance) states clients MUST prevent CSRF, and requires one-time-use, user-agent-bound CSRF tokens in the state parameter — this is a MUST, not a SHOULD, but it's conditional: it's the fallback that applies when the client cannot rely on PKCE (which itself provides CSRF protection when the authorization server supports it) or, in OpenID Connect flows, on the nonce parameter. RFC 6749 never mandated single-use state, and that gap is what makes state-replay attacks practical; RFC 9700 closes it by making single-use state mandatory precisely in the cases where PKCE/nonce aren't already covering CSRF. The mechanism differs (cookie/session token vs. redirect parameter vs. third-party challenge), but the invalidate-immediately-after-consumption discipline is the same in all three where a single-use token is the chosen protection.

How to reproduce, generalized: from a client with no authenticated session, complete the flow once (get a fresh token, submit, succeed). Immediately submit a second time without reloading the page or restarting the flow. Pre-fix, this fails every time, deterministically — it is not a race condition and retrying with the same cached token will not help, because the token is already burned. Post-fix, the second submit succeeds because the widget already re-armed itself after the first consumption.

What does NOT work

- Increasing the token TTL. This only delays how long the user can sit idle before the *expiry* trigger fires; it does nothing for the *reuse* trigger, which fails on the very next request regardless of TTL. Conflating the two triggers and "fixing" only one is a common half-fix. - Retrying the same request client-side on failure. Because the token was already consumed (or is already expired), a naive retry sends the identical bad token and fails again with the same error, deterministically. Treating this as a transient network blip and adding a retry loop wastes a retry budget on a failure that will never self-resolve without a fresh token. - Chasing CSP console warnings as the cause. If they're Report-Only, they're cosmetic noise that happens to coincide with the real failure in devtools, not a blocker. Confirm report-only-vs-enforce mode before spending time there. - Returning/checking only a boolean success flag server-side. It "works" in the sense that it correctly rejects the bad request, but it makes the root cause undiagnosable from logs alone — every future occurrence costs a fresh investigation instead of a log grep, because nobody recorded *why* verification failed. - Fixing only the frontend store, or only the widget, not both. The store can null the token correctly and the widget can still hold a stale one if the reset signal isn't wired through end to end — the two halves are independently necessary and a partial fix looks identical to no fix from the user's perspective (still fails on submit 2). - Assuming the fix needs to touch the authenticated path too. If authenticated requests never go through this challenge in the first place, don't add reset logic there — verify first whether the check even runs for authenticated sessions before spending effort on a path that was never affected.

Related

- A valid OAuth token is rejected by a preflight check because the preflight calls an endpoint that needs a different scope than the token actually has — a different way a token check fails intermittently: the token itself is valid and unexpired, but the wrong endpoint/scope is being asked to validate it. Distinguish by symptom: scope mismatch fails on a *specific* endpoint regardless of submit count; single-use reuse fails on the *n-th* submit regardless of endpoint.

Verified against

47 claims checked against these sources

Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.