Docs / Security / Token lifecycle
How Playtcha tokens work
There are two tokens in the Playtcha flow, not one. The browser first receives a challenge token (~2 minutes). After the user actually plays and the backend re-simulates the run, Playtcha returns a result token (5 minutes). Your server verifies that result token with your secret key. That separation is why a bot cannot skip straight from widget load to a valid server-side verification.
Overview
- The widget asks Playtcha for a challenge token at
/v1/challenge. - The widget then asks
/v1/render-planfor the per-load layout that the chosen game should render (widget protocol v=2). - The user plays one game against that token’s seed and challenge definition.
- The widget posts the outcome to
/v1/complete. - Playtcha re-validates the outcome and returns a result token.
- Your server posts
{ secret, token }to/v1/verify. - The result token verifies once and only once.
1. Challenge token
/v1/challenge issues a signed challenge JWT. It picks a game, a challenge, and a deterministic seed. This token is short-lived and browser-facing. It is not the thing your backend should ever trust directly.
The challenge token exists to bind the upcoming play session to a known origin, session id, game id, challenge id, and seed. That gives Playtcha enough context to reject fabricated completions later.
2. Complete and result token
The widget submits the played-out report to /v1/complete together with the original challenge token and the same session_id used at issuance. The backend checks the cheap invariants first, then runs deterministic replay for the selected game.
If the run is accepted, Playtcha returns a result_token. That is the only token your backend should send to /v1/verify. The browser still does not get a free pass: it only gets a token that was minted after replay succeeded.
3. Server-side verify
Your backend posts the client-provided result token plus your secret key to/v1/verify. A successful response looks like this:
{
"success": true,
"challenge_ts": "2026-05-08T12:00:00.000Z",
"hostname": "example.com",
"degraded": false,
"degraded_reason": null
}degraded: true is still a success. It signals usage-band state for the project, not a failed human verification.
On the wire, error responses are returned with a 4xx or 5xx status code and the envelope { error: { code, message, request_id, hint?, host? } } only. There is nosuccess: false field in the response body itself; some of our helper code synthesizes one as a local discriminator, but it is never sent by the server.
4. Dry-run verify for atomic signup
POST /v1/verify-dry takes the same { secret, token } input as/v1/verify, but it does not redeem the token and does not bump the project’s monthly usage. On success it returns { would_succeed: true }; on failure it returns the same 4xx envelopes that /v1/verify returns, with one important detail: an already-used result token still returns 409 already_redeemed.
Use this for atomic flows where verification has to happen before a side effect that can itself fail for unrelated reasons. The canonical example is signup: /v1/verify-dry, thensupabase.auth.signUp, then /v1/verify only if signUp returned success. This avoids burning the user’s captcha when their email is already registered, their password is too weak, or their session-token write fails on your side.
5. What the tokens are bound to
- Origin: the browser
Originheader must be allowed for the project. - Session id: the same widget session must be used at challenge and complete time.
- Game + challenge: the replay simulator uses the exact issued game, challenge id, and seed.
- Single use: the verification row is redeemed atomically, so the same result token cannot be verified twice.
This is why simply fabricating a hidden input in DevTools is not enough. The token has to come from a real issued challenge, survive replay, and then survive a single-use redeem step on /v1/verify.
6. Expiry and replay semantics
Both tokens are short-lived, with concrete TTLs:
- Challenge token: ~2 minutes. Issued by
/v1/challenge, consumed by/v1/complete. If the user takes longer than that to play, the widget retries with a fresh challenge. - Result token: 5 minutes. Returned by
/v1/complete, consumed by/v1/verify. Source of truth isRESULT_TTL_SECONDSinbackend/src/lib/env.ts(defaults to 300). If your server-side verify runs after that window, you get410 token_expired.
In addition, /v1/complete rejects immediate replays using server-issued time, not just client-reported duration. A user can load the widget, solve the game, and submit the form normally — but stale tokens, copied tokens, and already-redeemed tokens do not remain useful for long.
409 already_redeemed is a security signal. Result tokens are single-use by design. A 409 in production means one of: a replay attack, a stuck retry loop that posted twice, or a buggy double-submit. Log it with the request_id at warn level — do not silently retry. If you see a sustained rate, you have something worth investigating.
7. Relevant failure codes
| Where | Code | Meaning |
|---|---|---|
| Complete | session_mismatch | The session id at completion did not match issuance. |
| Complete | origin_mismatch | The host completing the run did not match the issued host. |
| Complete | token_expired | The challenge token TTL elapsed before completion. |
| Verify | token_expired | The result token TTL elapsed before your server verified it. |
| Verify | already_redeemed | The same result token was already used once. |
| Verify | bad_secret | The secret key did not match the project that owns the token. |
Where to go next
Pair this with the Node verify reference if you are implementing the backend now, the domain whitelist reference if you are debugging origin failures, or the Next.js guide if your form lives in App Router.