Skip to content

Serve the Turnstile/reCAPTCHA siteverify contract, check the secret, bind tokens to their origin - #28

Merged
cport1 merged 1 commit into
mainfrom
feat/siteverify-compat
Aug 19, 2026
Merged

Serve the Turnstile/reCAPTCHA siteverify contract, check the secret, bind tokens to their origin#28
cport1 merged 1 commit into
mainfrom
feat/siteverify-compat

Conversation

@cport1

@cport1 cport1 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Adds POST /turnstile/v0/siteverify, /recaptcha/api/siteverify and /siteverify to all three servers, accepting form-encoded and JSON bodies and answering the upstream response shape and error-code vocabulary. An existing Turnstile/reCAPTCHA/hCaptcha backend integration now works against FCaptcha by changing the base URL.

Three changes the contract requires:

  • secret is checked. All three servers previously read it out of the body and ignored it, so any caller who could reach /api/token/verify could spend a token. FCAPTCHA_VERIFY_SECRET splits the verify credential from the signing key; FCAPTCHA_LEGACY_UNAUTH_VERIFY restores the old behaviour for one release.
  • Tokens carry a signed hostname, action and cdata. The hostname is derived server-side from Origin (then Referer), so a backend can reject a token minted on a site that lifted the key or minted for a different action. FCAPTCHA_ALLOWED_HOSTNAMES optionally enforces it at mint time.
  • idempotency_key lets a retried validation return the first answer instead of tripping the single-use guard.

Also fixes a cross-server token format divergence found while building this: Go encoded padded base64url, Node unpadded, and Python signed a payload with different JSON separators, so no two implementations could verify each other's tokens. All three now emit unpadded base64url over compact sorted-key JSON and accept the old encodings, so rolling deploys and in-flight tokens are unaffected. Byte-identical fixtures in the three test files pin the format.

Old four-key tokens still verify — the signature covers whatever keys a token carries.

Breaking

/api/token/verify now requires secret. See Upgrading to 1.22.0.

Tests

34 Node, 14 Go, 30 Python covering hostname derivation, the allowlist, sanitising, the secret gate, error-code mapping, idempotent replay and the token round-trip. E2E suite updated and passing (104/104). Verified live that all three servers return identical siteverify responses across valid tokens, every error case, replay, idempotent retry and legacy tokens.

…ecret, bind tokens to their origin

Adds POST /turnstile/v0/siteverify, /recaptcha/api/siteverify and /siteverify to
all three servers, accepting form-encoded and JSON bodies with secret, response,
remoteip and idempotency_key, and answering the upstream shape and error-code
vocabulary. It is an adapter over the existing verifyToken in each server, not a
second verification path.

The reason to have it is adoption: every backend SDK, CMS plugin and snippet
written for those services speaks this contract already, so pointing an existing
integration at FCaptcha becomes a base-URL change rather than a rewrite.

Three things the native endpoint did not do, which the contract requires:

  - `secret` is now checked. All three servers read it out of the body and
    dropped it, so anyone who could reach /api/token/verify could spend a token
    — and since tokens are single-use, burn a real visitor's. The README has
    always documented sending it, which made the omission worse: integrators
    believed they were authenticated. FCAPTCHA_VERIFY_SECRET splits the verify
    credential from the signing key; FCAPTCHA_LEGACY_UNAUTH_VERIFY restores the
    old behaviour for one release.

  - Tokens carry a signed hostname, action and cdata. The hostname is derived
    server-side from Origin (then Referer), never from the request body, so a
    caller that could state its own hostname cannot. This is what lets a backend
    reject a token minted on a site that lifted the key, or minted for a
    different action. FCAPTCHA_ALLOWED_HOSTNAMES optionally enforces it at mint
    time; a request with no derivable origin still passes, because a native or
    server-side client legitimately has none and anyone able to forge an Origin
    would forge a listed one.

  - idempotency_key makes a retry safe. Keyed on the key and the token together:
    reusing one key across different tokens is a caller bug, and answering the
    second from the first's cache would report success for a token nobody
    validated.

Fixes a cross-server token format divergence found while building this. Go
encoded padded base64url, Node unpadded, and Python signed a payload with
json.dumps' default separators — a space after every ':' and ','. No two
implementations could verify each other's tokens. Nothing caught it because each
server only ever verified its own, and no test crossed the boundary; siteverify
is exactly where it would have started biting, since a backend may validate
against an instance that did not mint the token. All three now emit unpadded
base64url over compact sorted-key JSON and accept the old encodings, so rolling
deploys and in-flight tokens survive. Byte-identical fixtures in the three test
files pin the shared format.

Old four-key tokens still verify: the signature covers whatever keys a token
carries, so this is additive rather than a format break.

Tests: 34 Node, 14 Go, 30 Python covering hostname derivation, the allowlist,
label sanitising, the secret gate, error-code mapping, idempotent replay and the
token round-trip. The E2E suite now sends the secret and asserts a wrong one is
refused. Verified live that all three servers return identical siteverify
responses for valid tokens, every error case, replay, idempotent retry and
legacy tokens.
@cport1
cport1 merged commit 6b59aeb into main Aug 19, 2026
6 checks passed
@cport1
cport1 deleted the feat/siteverify-compat branch August 19, 2026 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant