Developer guideHow a check works

How a check works

What happens between the call and the pass: the challenge, the background work, the risk score, the hold, the puzzle and attack mode.

One attempt, end to end

  1. BrowserChallengeinit asks for a challenge: a salt, a difficulty and a count, signed by us. The look for your site comes with it. Workers start and wait.
  2. BrowserWorkOn showBox() the workers solve the proof-of-work: 64 small hash puzzles of 12 bits each by default. The page stays responsive.
  3. ServiceScoreThe solution, the browser signals and what the request itself shows are scored 0 to 100. Nothing is stored for a visitor who never gets this far.
  4. ServiceDecideBelow the threshold: a pass. Above it: a hold, or at a higher score a puzzle, each with harder background work. A visitor is never refused on the score alone.
  5. BrowserPassThe pass is five signed strings. The box, if there was one, shows "Verified" and closes. Your page sends the pass with the form.
  6. Your serverConfirmThe pass is checked once against your secret and spent. From then on it is worthless to anyone who copied it.

The challenge

A challenge is issued on init and must be solved within ten minutes. Each one can be used once: the salt is claimed when the solution is verified, so a solved challenge cannot be replayed into a second pass. The service stores nothing when it issues one, which is why an unsolved challenge costs it nothing and why a flood of init calls does not fill anything up.

JSON
// GET /load answer: the challenge the browser must solve. The service keeps none of it;
// the signature lets it trust the fields when they come back.
{
  "status": "success",
  "data": {
    "captcha_id": "[SITE KEY]",
    "lot_number": "3f9c2b6e4d1a4f0b9c8e7d6a5b4c3d2e",
    "salt": "…",
    "bits": 12,
    "count": 64,
    "tier": "normal",
    "issued_at": 1793955600123,
    "bind": "…",
    "sig": "…"
  },
  "theme": { "accent": "#1746D1", "radius": 12, "mode": "auto", "trust": true }   // only when set for the site
}

The difficulty is bits: each sub-puzzle needs a hash with that many leading zero bits, so every extra bit doubles the work. The work is split into count pieces so that it spreads across the workers and a slow piece holds up only itself. bind is a keyed hash of the browser and address that loaded the challenge, so a challenge farmed out to another machine shows at verification; it carries no address in clear.

The work

TierDefault workWhat the visitor does
Invisible12 bits, 64 piecesNothing.
Hold13 bits, 96 piecesPresses and holds for 1.5 seconds while the work runs.
Puzzle13 bits, 96 piecesSlides a piece into its gap. The hold offered as an alternative carries two more bits.

Solving runs in up to four Web Workers, started from the loader's own file, in WebAssembly where the page allows it and in JavaScript where it does not. If workers cannot start at all, the main thread solves in 8 millisecond slices so the page keeps responding. The verify request reports which engine was used and how long the solve took, and both are kept in your reports, so a slow population shows up as data rather than complaints.

The score

The service scores every verified solution from 0 to 100, adding a weight for each check that fires. Two views of the same evidence are kept, a network score and a browser score, so a report can say whether a wave came from where it connected or from what it ran.

Checks that count

  • Rates: more than 30 verifications a minute from one address, or more than 200 from one /24 network, by default. Crossing a rate adds to the score; it never blocks on its own.
  • The browser's own account: no signals at all, a declared automation flag, no screen size, no time zone, or a solve reported faster than a browser can hash.

Checks that are measured first

New checks are recorded before they count. Each one is kept in the record with the score it would have produced, and it is promoted to a counting check for a site only once real traffic shows that it does not catch real visitors. Measured today: known automation tool names on the window or in the user agent, missing browser-only request headers, an address in a hosting network, a browser or address that changed between the challenge and the verification, more than 120 verifications a minute from one browser fingerprint on one site, a device profile that does not fit its user agent, a page submitted too fast or watched for a while with no input at all, clicks with no pointer movement, a pointer path too straight to be a hand, and a drag on the puzzle too even to be one.

What decides a hold or a puzzle attempt

A hold is refused when it was shorter than the required 1.5 seconds, when less than that passed since the service issued it, or when an invisible honeypot field was filled. A slide is refused when the piece is left outside the 4 pixel tolerance, when the picture has expired, or when the attempts are used up. The shape of the drag is recorded with every slide, so the trace check above can be judged on real data before it ever refuses anyone.

Your records carry both the score and the "would" score, and every record names the version of the rules that produced it, so a change in the rules is never mistaken for a change in your traffic.

The decision

JSON
// POST /verify answers, one of four. The loader acts on "mode"; your code only ever sees the events.
{ "status": "success", "data": { "mode": "pass", "result": { …the five strings… } } }
{ "status": "success", "data": { "mode": "hold", "challenge": { …tier "hold"… }, "hold_ms": 1500 } }
{ "status": "success", "data": { "mode": "puzzle", "challenge": { …tier "puzzle"… }, "alt": { …tier "hold"… }, "hold_ms": 1500, "attempts": 3 } }
{ "status": "success", "data": { "mode": "fail" } }
OutcomeWhenThen
passThe score is below the hold threshold: 60 by default.The pass is issued at once.
holdThe score is at or above the hold threshold.A new signed challenge with harder work and a 1.5 second hold. The service checks the time held, that at least that long passed since it issued the hold, and that the honeypot stayed empty. A refused hold can be held again.
puzzleThe score is at or above the puzzle threshold, 80 by default, and the puzzle is enabled for the service.A puzzle challenge plus a hold challenge as the alternative. The picture is fetched separately and is valid for two minutes. Three attempts by default; after the third miss, or on "Use a different check", the hold is used.
failThe signature does not check out, the challenge is older than ten minutes, the proof-of-work is wrong, or the site key is unknown.The loader fires fail and then error with code 60500. A new init is needed.

A hold or a puzzle that is refused answers fail too, but the loader keeps the box open and lets the visitor try again, so your page sees fail events and still a success later. No risk score refuses a visitor by itself: the most it can do is ask for a hold or a puzzle.

Attack mode

Each site key is watched on its own. When traffic for a key surges or turns suspicious, its level rises by itself, and only the work and the thresholds change: nobody is blocked by the level. The level steps down one notch after ten quiet minutes, and attack minutes stay out of the baseline so a long attack never becomes the new normal.

LevelRaised when, either oneInvisible workHold from scorePuzzle from score
0Normal12 bits6080
1Solves a minute reach 5 times usual and at least 10; or, within 5 minutes, 10 suspicious solves make up 30% of the total13 bits (2x)4060
215 times usual and at least 30; or 30 suspicious solves make up 50%14 bits (4x)2040

At level 1 and above the hold and puzzle tiers also carry one more bit. Only solved challenges count towards a level, so raising a site's level costs an attacker real work, and the most it can achieve is to make that site's invisible check four times harder. The thresholds are evaluated per service instance, and after a restart volume alone is ignored for 30 minutes while a baseline is built. A self-hosted service can turn attack mode off; every level change is logged and counted.

Level changes are kept as attack timelines in your reports: when it started, what raised it, which networks and countries took part, and when it ended. See Reports and data.

The pass

A pass is five strings. lot_number is the attempt's identifier, 32 hex characters, chosen at random when the challenge was issued. captcha_output encodes the tier and the score, base64url. gen_time is the time of the pass in unix seconds. pass_token is 64 hex characters, an HMAC-SHA256 under your secret over the site key, the lot number, the output and the time, so changing any of them, or presenting the pass under another site key, makes it fail. It is spent by the first server check that reaches a decision, and it expires five minutes after gen_time.