Skip to content

Human Check

A bot check for your forms that asks visitors nothing. Their browser solves a small proof-of-work in the background, your server verifies the result, and nothing is stored on their device. It speaks Cloudflare Turnstile's API, so switching is a change of URLs and keys.

How it works

The widget loads in a small iframe served by HostStack. When the page renders it, the iframe asks for a challenge, solves it in Web Workers, and trades the solution for a token. The token lands in a hidden cf-turnstile-response field in your form. Your server sends that token to siteverify with your secret and gets a yes or no.

  • The work is HashWX, a hash that runs a freshly generated program for every block of 65,536 attempts. A GPU solves it about 2× faster than a CPU — against about 150× for SHA-256 — so renting GPUs does not make a bot cheap. Each challenge is split into four sub-puzzles, solved in parallel, so the time is steady and there is a progress bar.
  • Tokens live 300 seconds and verify once. A second siteverify with the same token answers timeout-or-duplicate.
  • Nothing is stored on the device — no cookies, no local storage — and no fingerprint is kept. See Privacy.

Set it up

  1. Add a site

    Open Human Check in the dashboard and click Add site. Enter the hostnames your form runs on — each covers its subdomains, and localhost works for development. You get a public sitekey and a secret. The secret is shown once; HostStack keeps only a hash of it.

  2. Add the widget to your form

    signup.html
    <script src="https://hoststack.dev/api/human-check/v0/api.js" async defer></script>
    
    <form method="post" action="/signup">
      <!-- … your fields … -->
      <div class="cf-turnstile" data-sitekey="hcs_..."></div>
      <button type="submit">Sign up</button>
    </form>
  3. Verify the token on your server

    Before acting on the form, POST the token to siteverify. Form-encoded and JSON bodies both work.

    const res = await fetch('https://hoststack.dev/api/human-check/v0/siteverify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        secret: process.env.HUMAN_CHECK_SECRET,
        response: body['cf-turnstile-response'],
        remoteip: clientIp, // optional
        idempotency_key: crypto.randomUUID(), // optional: makes a retry safe
      }),
    });
    const verdict = await res.json();
    if (!verdict.success) throw new Error('Human verification failed');

Moving from Turnstile

Change three things — the script URL, the sitekey and secret, and the siteverify URL. Everything else is the same API:

  • .cf-turnstile elements render automatically (so do .hs-human-check ones), and ?render=explicit and ?onload= work on the script URL.
  • window.turnstile has render, execute, reset, remove, getResponse, isExpired, ready and implicitRender. The same object is window.hoststackHumanCheck; a page that also loads Cloudflare's script keeps Cloudflare's window.turnstile.
  • The token arrives in the same cf-turnstile-response field.
  • siteverify takes the same fields and answers in the same shape, with the same error codes.
  • Cloudflare's test keys keep working — see Testing.

Widget options

Set them as data-* attributes, or pass them to turnstile.render(); camelCase names like cData and errorCallback are accepted too.

sitekey
stringrequired
The site's public key.
action
string
A label for this form, up to 32 of A–Z a–z 0–9 _ -. Returned by siteverify, so a login token cannot be spent on your signup form unnoticed.
cdata
string
Up to 255 of the same characters, returned by siteverify untouched.
callback, error-callback, expired-callback, timeout-callback
function
Called with the token, an error code, or nothing. Give a global function name in a data- attribute, or a function to render().
before-interactive-callback, after-interactive-callback, unsupported-callback
function
Around the click in managed mode, and for a browser that cannot run the check.
theme
"auto" | "light" | "dark"
Colours of the visible widget.

Default: auto

size
"normal" | "flexible" | "compact"
300×65, full width × 65, or 150×140 — Turnstile's sizes.

Default: normal

language
string
Twelve languages, picked from the browser by default.

Default: auto

execution
"render" | "execute"
execute waits for turnstile.execute() before solving.

Default: render

appearance
"always" | "execute" | "interaction-only"
When the visible widget is shown.

Default: always

response-field, response-field-name
boolean, string
Whether to add the hidden input, and its name.

Default: true, cf-turnstile-response

retry, retry-interval
"auto" | "never", ms
Retrying after a failure that is not a configuration error.

Default: auto, 8000

refresh-expired
"auto" | "manual" | "never"
What happens when an unused token expires.

Default: auto

Explicit rendering

html
<script src="https://hoststack.dev/api/human-check/v0/api.js?render=explicit&onload=onHumanCheckReady" async defer></script>

<div id="check"></div>
<script>
  function onHumanCheckReady() {
    const id = turnstile.render('#check', {
      sitekey: 'hcs_...',
      action: 'signup',
      callback: (token) => console.log('verified', token),
      'error-callback': (code) => console.warn('check failed', code),
    });
    // turnstile.getResponse(id), turnstile.reset(id), turnstile.remove(id)
  }
</script>

Check on submit

js
turnstile.render('#check', { sitekey: 'hcs_...', execution: 'execute' });

form.addEventListener('submit', (e) => {
  if (!turnstile.getResponse('#check')) {
    e.preventDefault();
    turnstile.execute('#check'); // starts the check now; callback fires when done
  }
});

Content Security Policy

The solver, its WebAssembly and its requests all run inside HostStack's frame, under HostStack's own policy. Your page only needs to allow the script and the frame:

Content-Security-Policy:
  script-src 'self' https://hoststack.dev;
  frame-src  https://hoststack.dev;

Modes and difficulty

ModeWhat the visitor sees
invisibleNothing. The check runs while they fill in the form. The default.
non-interactiveA small status box that verifies by itself.
managedThe status box — and, for a visitor who looks risky, one click before the check starts.

Difficulty sets the base cost of a solve, as expected HashWX evaluations: low 250,000, normal 500,000, high 1,000,000. On a typical phone, normal takes about a second, usually while the visitor is still typing.

Escalation

The price goes up for visitors that look like automation:

  • Asking often — more than 8 challenges a minute from one address (an IPv4 address or an IPv6 /64) doubles it, more than 20 quadruples it, more than 50 makes it eight times. Busy networks (/24 or /48) add more.
  • Where it comes from — datacenter addresses pay 4×, VPNs 2×. iCloud Private Relay and WARP are flagged but not charged, because ordinary people use them.
  • What it is — an HTTP library instead of a browser pays 4×, a headless or automated browser 2×, and signals the browser reports about itself (webdriver, software rendering and similar) up to 4×.

All of it together is capped at 16×. Past 120 challenges a minute from one address the widget gets 429 rate-limited instead of a puzzle.

Verifying tokens

POST https://hoststack.dev/api/human-check/v0/siteverify, form-encoded or JSON. It always answers 200; read success.

secret
stringrequired
The site's secret (hcsec_…).
response
stringrequired
The token from cf-turnstile-response.
remoteip
string
The visitor's address. Used only to answer metadata.ip_match.
idempotency_key
UUID
Send the same key on a retry and you get the first answer back instead of timeout-or-duplicate.
200 OK
{
  "success": true,
  "error-codes": [],
  "challenge_ts": "2026-11-10T14:03:11.000Z",
  "hostname": "shop.example.com",
  "action": "signup",
  "cdata": "",
  "metadata": {
    "risk_flags": ["ip_vpn"],
    "difficulty_multiplier": 2,
    "solve_ms": 1840,
    "ip_match": true
  }
}

metadata is ours, on top of Turnstile's fields: risk_flags (why the check was harder, or what looked odd), difficulty_multiplier, solve_ms, and ip_match — whether remoteip is on the network the token was solved from. They are signals: success already means the work was done. Use them to add your own friction, such as an email confirmation, for a risky-looking signup.

Error codeMeaning
missing-input-secretNo secret was sent.
invalid-input-secretThe secret is wrong, or was rotated more than 24 hours ago.
missing-input-responseNo token was sent.
invalid-input-responseNot a token, or a token for a different site.
bad-requestThe request could not be read.
timeout-or-duplicateThe token is older than 300 seconds, or was already verified.
internal-errorHostStack could not check single use. Retry with the same idempotency_key.

Testing

Cloudflare's published test keys behave as they do on Turnstile, so CI written for Turnstile keeps passing. The test sitekeys produce the dummy token XXXX.DUMMY.TOKEN.XXXX, which only the test secrets accept.

KeyBehaviour
1x00000000000000000000AASitekey: always passes (visible)
2x00000000000000000000ABSitekey: always fails (visible)
1x00000000000000000000BBSitekey: always passes (invisible)
2x00000000000000000000BBSitekey: always fails (invisible)
3x00000000000000000000FFSitekey: asks for a click, then passes
1x0000000000000000000000000000000AASecret: always passes
2x0000000000000000000000000000000AASecret: always fails
3x0000000000000000000000000000000AASecret: answers timeout-or-duplicate

Keys and rotation

The sitekey is public. The secret is shown once, when you create the site or rotate it. A rotation gives you a new secret at once, and the old one keeps verifying for 24 hours so you can deploy without refusing anyone. Deleting a site stops its tokens verifying immediately.

What it does not do

  • It prices bots; it does not stop them outright. A determined attacker can pay the work, and humans paid to solve checks get through any captcha. Keep your own rate limits and email verification behind it.
  • Browser signals can be faked. They only make a solve dearer and are reported in risk_flags; nothing is refused on them alone.
  • No WebAssembly — Apple's Lockdown Mode turns it off — gets a SHA-256 puzzle at twice the work instead, flagged no_wasm.

Privacy

The widget sets no cookies and writes nothing to the device. The visitor's IP address is used for per-minute rate counters that expire after two minutes, and inside the token as a keyed tag of their network (not the address) so siteverify can answer ip_match. Browser signals are sent with the challenge request and kept only as flags inside that token. There is no image puzzle, so it meets WCAG 2.2's no-cognitive-test rule; status changes are announced to screen readers.

Next steps

Was this page helpful?