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
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
localhostworks for development. You get a public sitekey and a secret. The secret is shown once; HostStack keeps only a hash of it.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>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');<?php $ch = curl_init('https://hoststack.dev/api/human-check/v0/siteverify'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_POSTFIELDS => http_build_query([ 'secret' => getenv('HUMAN_CHECK_SECRET'), 'response' => $_POST['cf-turnstile-response'] ?? '', 'remoteip' => $_SERVER['REMOTE_ADDR'], ]), ]); $verdict = json_decode(curl_exec($ch), true); if (empty($verdict['success'])) { http_response_code(400); exit('Human verification failed'); }curl -X POST https://hoststack.dev/api/human-check/v0/siteverify \ --data-urlencode "secret=$HUMAN_CHECK_SECRET" \ --data-urlencode "response=$TOKEN" \ --data-urlencode "remoteip=203.0.113.7"
Moving from Turnstile
Change three things — the script URL, the sitekey and secret, and the siteverify URL. Everything else is the same API:
.cf-turnstileelements render automatically (so do.hs-human-checkones), and?render=explicitand?onload=work on the script URL.window.turnstilehasrender,execute,reset,remove,getResponse,isExpired,readyandimplicitRender. The same object iswindow.hoststackHumanCheck; a page that also loads Cloudflare's script keeps Cloudflare'swindow.turnstile.- The token arrives in the same
cf-turnstile-responsefield. - 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.
sitekeystringrequired- The site's public key.
actionstring- 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. cdatastring- Up to 255 of the same characters, returned by siteverify untouched.
- callback, error-callback, expired-callback, timeout-callbackfunction
- Called with the token, an error code, or nothing. Give a global function name in a
data-attribute, or a function torender(). - before-interactive-callback, after-interactive-callback, unsupported-callbackfunction
- 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 languagestring- Twelve languages, picked from the browser by default.
Default:
auto execution"render" | "execute"executewaits forturnstile.execute()before solving.Default:
renderappearance"always" | "execute" | "interaction-only"- When the visible widget is shown.
Default:
always - response-field, response-field-nameboolean, 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
<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
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
| Mode | What the visitor sees |
|---|---|
| invisible | Nothing. The check runs while they fill in the form. The default. |
| non-interactive | A small status box that verifies by itself. |
| managed | The 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.
secretstringrequired- The site's secret (
hcsec_…). responsestringrequired- The token from
cf-turnstile-response. remoteipstring- 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.
{
"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 code | Meaning |
|---|---|
| missing-input-secret | No secret was sent. |
| invalid-input-secret | The secret is wrong, or was rotated more than 24 hours ago. |
| missing-input-response | No token was sent. |
| invalid-input-response | Not a token, or a token for a different site. |
| bad-request | The request could not be read. |
| timeout-or-duplicate | The token is older than 300 seconds, or was already verified. |
| internal-error | HostStack 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.
| Key | Behaviour |
|---|---|
| 1x00000000000000000000AA | Sitekey: always passes (visible) |
| 2x00000000000000000000AB | Sitekey: always fails (visible) |
| 1x00000000000000000000BB | Sitekey: always passes (invisible) |
| 2x00000000000000000000BB | Sitekey: always fails (invisible) |
| 3x00000000000000000000FF | Sitekey: asks for a click, then passes |
| 1x0000000000000000000000000000000AA | Secret: always passes |
| 2x0000000000000000000000000000000AA | Secret: always fails |
| 3x0000000000000000000000000000000AA | Secret: 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?