A Token That Proves One Action, On One Host, Once

A managed challenge looks like a checkbox and behaves like a signed assertion. The widget renders, the script runs, a callback hands your code a token, you post that token to your own origin, and the origin exchanges it with the challenge vendor for a verdict. The verdict is not a session and not a login. It says one thing: this challenge, for this action, on this hostname, was cleared once.

That narrowness is the design, and it is the reason the flow behaves so differently from the older interactive gate described in CAPTCHAs in Session Flows. There is nothing to solve visually, nothing to hand to a human, and nothing to post twice. The execute-and-probe stage is the same machinery as any JavaScript challenge, and the chain of claims it produces is a cousin of the hand-rolled proof in Proof-of-Work Solvers in Practice. The snippet below models the lifecycle as an explicit state machine, drives it with a fixed event script, and then checks the same transitions against the validation codes the endpoint actually returns.

Eight States and the Events That Move Between Them

Modelling the flow as a machine rather than as a sequence of callbacks gives you three things for free: illegal steps become visible instead of silent, expiry has an obvious home, and replay has a state to land in.

The eight states are absent, rendered, executing, issued, submitted, accepted, spent and expired. Six event kinds drive them: rend for a render, exec for the script starting, tokn for the callback, post, vrfy for the validation round trip, repl for a second use of the same token, ttl for the server-side clock running out, and clrr for teardown.

The transition grid is the artefact to keep. Read a column down to see what a given event does from every state, and any cell with a dash is a step your code should never be capable of taking. This is the same discipline a defender applies when reasoning about which request came from which page: a state you cannot name is a state you cannot measure, and an event with no legal home is usually the first sign that your instrumentation is telling you a story the wire does not support.

'''Token lifecycle state machine for a managed challenge flow.

Everything here is driven by a fixed event script and a fixed clock offset, so
the transition table and the validation table are the same on every run. The
siteverify error codes are the ones the validation endpoint actually returns.
'''

FIELD = "cf-turnstile-response"
TTL_SECONDS = 300
SINGLE_USE = True

# Short codes, so the transition grid fits a phone screen.
ABBR = {
    "absent": "abs", "rendered": "rnd", "executing": "exe", "issued": "tok",
    "submitted": "sub", "accepted": "ok", "spent": "spt", "expired": "exp",
}
EVENTS = ("render", "execute", "token", "post", "verify", "replay", "ttl", "clear")

# (state, event) -> next state. Anything not listed here is an illegal
# transition, and an illegal transition is the interesting case, not the
# successful one: a token replayed is not "still accepted".
TRANSITIONS = {
    ("absent", "render"): "rendered",
    ("rendered", "execute"): "executing",
    ("executing", "token"): "issued",
    ("issued", "post"): "submitted",
    ("submitted", "verify"): "accepted",
    ("accepted", "replay"): "spent",
    ("accepted", "clear"): "absent",
    ("accepted", "ttl"): "expired",
    ("spent", "clear"): "absent",
    ("expired", "clear"): "absent",
    ("issued", "clear"): "absent",
}

# A fixed script: one full pass through the machine, then the three ways a
# token dies. Offset is seconds since the challenge was served.
SCRIPT = [
    (0, "render", "widget mounted in invisible mode, no user interaction needed"),
    (1, "execute", "challenge script starts, probes the environment"),
    (4, "token", "callback fires with a token in " + FIELD),
    (5, "post", "form or fetch posts the token to the origin"),
    (6, "verify", "origin calls siteverify, gets success plus challenge_ts"),
    (7, "replay", "the same token is posted a second time"),
    (8, "clear", "widget removed, a fresh widget is armed for the next action"),
    (9, "render", "second arm, managed mode, widget asks for a real click"),
    (10, "execute", "challenge script starts again"),
    (13, "token", "callback fires with a second token"),
    (14, "post", "posted to the origin"),
    (15, "verify", "accepted, challenge_ts recorded"),
    (404, "ttl", "presented again 389 s later, past the 300 s ttl"),
    (405, "clear", "context closed, cookie jar handed to the pool"),
    (406, "replay", "presented after expiry and after clear: not even a legal step"),
]


def step(state, event):
    return TRANSITIONS.get((state, event), None)


def siteverify(record, context):
    '''Model of POST /siteverify. Returns (success, code) using the real codes.'''
    if not context.get("secret"):
        return False, "missing-input-secret"
    if record is None:
        return False, "invalid-input-response"
    if record["age"] > TTL_SECONDS:
        return False, "timeout-or-duplicate"
    if SINGLE_USE and record["used"]:
        return False, "timeout-or-duplicate"
    if record["sitekey"] != context["sitekey"]:
        return False, "missing-input-sitekey"
    if record["action"] != context["action"]:
        return False, "invalid-input-action"
    if record["hostname"] != context["hostname"]:
        return False, "invalid-input-hostname"
    if record["cdata"] and record["cdata"] != context["cdata"]:
        return False, "invalid-input-response"
    return True, "success"


ISSUED = {
    "sitekey": "0x4AAAAAAA7bWqX2Rt", "action": "checkout",
    "hostname": "shop.example", "issued_at": 0, "cdata": "",
}
TOKEN = dict(ISSUED)
TOKEN.update({"issued_at": 4, "used": False})

ORIGIN = {"secret": "0x4AAAAAAA_hK9pQz7Wm1", "sitekey": "0x4AAAAAAA7bWqX2Rt",
          "action": "checkout", "hostname": "shop.example", "cdata": ""}

SUBMISSIONS = [
    ("fresh token, matching origin", TOKEN, ORIGIN),
    ("same token, second submit", dict(TOKEN, used=True), ORIGIN),
    ("token aged past 300 s", dict(TOKEN, issued_at=0, age=412), ORIGIN),
    ("token minted for another host", dict(TOKEN, hostname="eu.example"), ORIGIN),
    ("token for a different action", dict(TOKEN, action="login"), ORIGIN),
    ("token for another sitekey", dict(TOKEN, sitekey="0x4AAAAAAA9zKpL1Qd"), ORIGIN),
    ("cdata echoed back wrong", dict(TOKEN, cdata="job-8814"),
     dict(ORIGIN, cdata="job-9901")),
    ("no token in the request", None, ORIGIN),
    ("fresh token, no secret configured", TOKEN, dict(ORIGIN, secret="")),
]

print("managed challenge flow: {} states, {} legal transitions, token ttl {} s".format(
    len(ABBR), len(TRANSITIONS), TTL_SECONDS))
print()
print("transition grid: rows are states, columns are events, cells are the next state")
print("{:<7} {:>5} {:>5} {:>5} {:>5} {:>5} {:>5} {:>5} {:>5}".format(
    "state", "rend", "exec", "tokn", "post", "vrfy", "repl", "ttl", "clrr"))
print("-" * 62)
order = ["absent", "rendered", "executing", "issued", "submitted",
         "accepted", "spent", "expired"]
for state in order:
    cells = []
    for event in EVENTS:
        nxt = step(state, event)
        cells.append(ABBR[nxt] if nxt else "-")
    print("{:<7} {:>5} {:>5} {:>5} {:>5} {:>5} {:>5} {:>5} {:>5}".format(state, *cells))
print()
print("the only path from absent to accepted is render, execute, token, post, verify.")
print("replay moves accepted to spent, so the second use is not accepted again; a")
print("token that is never posted can still expire, and expiry is checked server-side.")

print()
print("trace of a fixed event script (offset in seconds since the challenge)")
print("{:<6} {:<9} {:<9} {:>6}  {}".format("offset", "event", "state", "legal", "what is happening"))
print("-" * 104)
state = "absent"
offset = 0
for offset, event, note in SCRIPT:
    nxt = step(state, event)
    legal = "yes" if nxt else "NO"
    state = nxt or state
    print("{:<6} {:<9} {:<9} {:>6}  {}".format(offset, event, state, legal, note))

print()
print("siteverify results for a fixed set of submissions")
print("{:<34} {:<6} {}".format("submission", "result", "code"))
print("-" * 96)
ok_codes = {}
for label, record, context in SUBMISSIONS:
    record = None if record is None else dict(record, age=record.get("age", 6))
    good, code = siteverify(record, context)
    ok_codes[code] = ok_codes.get(code, 0) + 1
    print("{:<34} {:<6} {}".format(label, "accept" if good else "reject", code))
print()
for code in sorted(ok_codes):
    print("  {:<26} x{}".format(code, ok_codes[code]))

print()
print("what a token is bound to, and what it is not")
print("{:<26} {:<10} {:<10}  {}".format("token claim", "checked?", "mismatch", "siteverify code"))
print("-" * 96)
BINDING = [
    ("sitekey", True, True, "missing-input-sitekey"),
    ("action", True, True, "invalid-input-action"),
    ("hostname", True, True, "invalid-input-hostname"),
    ("issued timestamp", True, True, "timeout-or-duplicate"),
    ("single use", True, True, "timeout-or-duplicate"),
    ("cdata echo", False, False, "silent: not always compared"),
    ("client IP", False, False, "optional remoteip, not binding"),
    ("user agent", False, False, "not part of the claim"),
    ("cookies or session", False, False, "not part of the claim"),
    ("account identity", False, False, "not part of the claim"),
]
for claim, checked, enforced, code in BINDING:
    print("{:<26} {:<10} {:<10}  {}".format(
        claim, "yes" if checked else "optional",
        "rejected" if enforced else "accepted", code))
print()
print("rows marked accepted are why a token is not a session: it proves a challenge")
print("was cleared for one action on one hostname, and carries no login state,")
print("no cart, and no permission to call the API again.")
managed challenge flow: 8 states, 11 legal transitions, token ttl 300 s

transition grid: rows are states, columns are events, cells are the next state
state    rend  exec  tokn  post  vrfy  repl   ttl  clrr
--------------------------------------------------------------
absent    rnd     -     -     -     -     -     -     -
rendered     -   exe     -     -     -     -     -     -
executing     -     -   tok     -     -     -     -     -
issued      -     -     -   sub     -     -     -   abs
submitted     -     -     -     -    ok     -     -     -
accepted     -     -     -     -     -   spt   exp   abs
spent       -     -     -     -     -     -     -   abs
expired     -     -     -     -     -     -     -   abs

the only path from absent to accepted is render, execute, token, post, verify.
replay moves accepted to spent, so the second use is not accepted again; a
token that is never posted can still expire, and expiry is checked server-side.

trace of a fixed event script (offset in seconds since the challenge)
offset event     state      legal  what is happening
--------------------------------------------------------------------------------------------------------
0      render    rendered     yes  widget mounted in invisible mode, no user interaction needed
1      execute   executing    yes  challenge script starts, probes the environment
4      token     issued       yes  callback fires with a token in cf-turnstile-response
5      post      submitted    yes  form or fetch posts the token to the origin
6      verify    accepted     yes  origin calls siteverify, gets success plus challenge_ts
7      replay    spent        yes  the same token is posted a second time
8      clear     absent       yes  widget removed, a fresh widget is armed for the next action
9      render    rendered     yes  second arm, managed mode, widget asks for a real click
10     execute   executing    yes  challenge script starts again
13     token     issued       yes  callback fires with a second token
14     post      submitted    yes  posted to the origin
15     verify    accepted     yes  accepted, challenge_ts recorded
404    ttl       expired      yes  presented again 389 s later, past the 300 s ttl
405    clear     absent       yes  context closed, cookie jar handed to the pool
406    replay    absent        NO  presented after expiry and after clear: not even a legal step

siteverify results for a fixed set of submissions
submission                         result code
------------------------------------------------------------------------------------------------
fresh token, matching origin       accept success
same token, second submit          reject timeout-or-duplicate
token aged past 300 s              reject timeout-or-duplicate
token minted for another host      reject invalid-input-hostname
token for a different action       reject invalid-input-action
token for another sitekey          reject missing-input-sitekey
cdata echoed back wrong            reject invalid-input-response
no token in the request            reject invalid-input-response
fresh token, no secret configured  reject missing-input-secret

  invalid-input-action       x1
  invalid-input-hostname     x1
  invalid-input-response     x2
  missing-input-secret       x1
  missing-input-sitekey      x1
  success                    x1
  timeout-or-duplicate       x2

what a token is bound to, and what it is not
token claim                checked?   mismatch    siteverify code
------------------------------------------------------------------------------------------------
sitekey                    yes        rejected    missing-input-sitekey
action                     yes        rejected    invalid-input-action
hostname                   yes        rejected    invalid-input-hostname
issued timestamp           yes        rejected    timeout-or-duplicate
single use                 yes        rejected    timeout-or-duplicate
cdata echo                 optional   accepted    silent: not always compared
client IP                  optional   accepted    optional remoteip, not binding
user agent                 optional   accepted    not part of the claim
cookies or session         optional   accepted    not part of the claim
account identity           optional   accepted    not part of the claim

rows marked accepted are why a token is not a session: it proves a challenge
was cleared for one action on one hostname, and carries no login state,
no cart, and no permission to call the API again.

The Only Legal Path, and the One That Always Fails

There is exactly one path from absent to accepted: render, execute, token, post, verify. Every other route involves an event in a state where it has no meaning.

Two details in the grid matter more than they look. repl moves accepted to spent, so the second presentation of a token is not rejected as malformed, it is recognised as a replay and lands in a state of its own. And clrr from spent goes back to absent rather than to rendered, which is how you end up with a live widget on a page whose token was already spent.

The trace's last row is the interesting one: presenting a token after expiry and after clear is not merely rejected, it is not a legal step. Your state machine should be able to reject that locally instead of spending a round trip to learn it from a validation code. Local rejection is also cheaper to log, and it keeps a malformed flow from looking identical to a genuine server-side refusal in your dashboards.

A Fixed Script Beats a Fuzz Test for This

The script is deterministic on purpose. Sixteen events at fixed offsets produce the same trace every run, so the one row marked NO is a regression detector, not a one-off observation.

Watch the offsets rather than the states. The gap from execute to token is three seconds in managed mode and much longer when the widget decides it wants a real click; the gap from token to post is yours to control; and the 389-second presentation at the end sits well past the 300-second token lifetime. Time to first token is the single most useful number to log from a real run, because it tells you whether the widget is in invisible mode or asking for interaction, and those two imply completely different handling. A fuzz test would produce a hundred traces like this one and none of them would be stable enough to assert on; the fixed script gives you one trace you can diff against the next build.

The Validation Endpoint Is Where Claims Become Rejections

The siteverify table is the part that surprises people. A token that looks perfect can be rejected for a reason that has nothing to do with whether the challenge was cleared: a token minted for another hostname, a token minted for a different action, a token echoed back with the wrong cdata.

Map every rejection code to one cause and one fix. invalid-input-hostname means your token came from a different sitekey or a different origin than the one you verified against. missing-input-sitekey means the server key is not configured at all, which in a staging environment is the single most common cause of a challenge that looks like it is failing for no reason. timeout-or-duplicate covers both a stale token and a replayed one, so you cannot distinguish them from the code alone; log the age. invalid-input-action is usually a copy-paste between two forms on the same page, and it is worth checking action names before you check anything else.

What a Token Is Bound To, and What It Is Not

The binding table is the one to memorise, because it is the difference between a token and a session. Checked and rejected on mismatch: the sitekey, the action, the hostname, the issue timestamp, and single use.

Everything else in the lower half is optional and, when present, silently accepted: the cdata echo, the client IP via remoteip, the user agent, the session cookies, the account identity. Read the "accepted" column as "not binding". If your flow depends on the token carrying identity, it will work in development, where remoteip is constant and there is one browser, and fail the first time a request arrives from a different address or a second tab. A token is closest in nature to a fingerprint match than to a login: it says the environment looked consistent at one moment, not that the caller is who they claim to be, and how anti-bots work is the wider picture of how that consistency is scored.

Failure Modes Worth Naming Before You Meet Them

Four failures account for nearly everything. A widget that never fires its callback, usually because the challenge script was blocked or the container is detected. A callback that fires with an empty token. A token posted to the wrong origin. And a token that validates but arrives after the user has already left, which is why the post belongs as close to the action as possible rather than at page load.

The empty-token case deserves its own log line, because it is the one that looks like a network problem and is not. Distinguishing "callback never fired" from "callback fired with nothing" takes one field in your event log and saves an afternoon of staring at request logs. The wrong-origin case is the second most common, and it is invisible unless you log the hostname the token was minted for alongside the hostname you verified against.

Checklist

  • Model the flow as a state machine, and make illegal transitions impossible to take rather than merely unlikely.
  • Log the offset of every event from challenge render, especially time to token.
  • Map each validation code to one cause; treat unknown codes as configuration problems before you treat them as policy refusals.
  • Verify against the hostname the token was minted for, not the current origin.
  • Keep the post adjacent to the action, not at page load.
  • Expect no identity in the token, and never treat acceptance as authentication.
  • Distinguish a missing callback from an empty token in your logs, and alert on the two separately.
  • Record the action name and sitekey on every verification so a copy-paste mismatch is visible without a packet capture.

The Legitimate Route

For your own site, the correct amount of work is the least that distinguishes a person from a script, and the vendor's own documentation lists the action names and test keys for exactly that. If you need to automate against someone else's site, an official API or a server-side integration with the challenge vendor covers most real integrations and hands you the verification endpoint rather than the widget; the token flow above is the piece you would otherwise be hand-rolling badly. The line you do not cross is forging a token for another user's account or re-presenting an accepted token after a block: both turn a consistency check into credential forgery, which is a different act with a different consequence.