Prove there’s a human.
GATillGate is the human check inside TillShield. A widget issues a signed pass token after the visitor’s browser completes a client-side proof-of-work; your server verifies that token at /siteverify. Privacy-first — no third-party tracking, no cross-site cookies — and honest about what it is: a cost on automation plus hostname and replay binding, not a behavioral bot detector.
Three moving parts
- Proof-of-work. The widget asks the browser to find a nonce whose hash has a configured number of leading zero bits. Finding it costs CPU; the harder the difficulty, the more it costs — cheaply for one visitor, expensively at bot scale.
- A signed pass token. On success the widget writes a short-lived, signed token into a form field named
tillgate-responseand calls yourdata-callback. The token is bound to your hostname and carries a short TTL. - Server-side verification. Your backend sends the token and your secret key to /siteverify and gets back
success, the hostname, and a challenge timestamp.
That is the entire mechanism. TillGate raises the cost of mass automation and binds each pass to the page that issued it. It does not fingerprint visitors or run a model — position it as friction on cheap automation, not a silver bullet.
Concepts & keys
Each project gets a key pair, minted under Shield → TillGate:
tg_site_…— the site key. Public, embedded in your page markup. It only identifies which project a pass belongs to; safe to ship to the browser.tg_secret_…— the secret key. A server credential used only in your /siteverify call.
tg_secret_… key in client code, a bundle, or a repository. It belongs only in your backend’s /siteverify call.Hosted script
The fastest path. Add the hosted widget script, then drop a tillgate element with your site key inside the form you want to protect. The widget renders itself, runs the proof-of-work, and puts the pass token in a hidden tillgate-response field that submits with the form.
<script src="https://tilldev.dev/tillgate.js" async defer></script>
<form method="POST" action="/login">
<div class="tillgate" data-sitekey="tg_site_…"></div>
<button type="submit">Sign in</button>
</form>Attributes configure the widget: data-sitekey (required), data-mode (managed / invisible / interactive), data-callback (a global function name called with the token), and — for Fortified sites — data-attest-callback (a global function that returns a device attestation).
npm package
For single-page apps, install the package and render the widget into an element you control. The callback receives the pass token directly.
npm i @tillstack/tillgateimport { render } from '@tillstack/tillgate'
render('#gate', {
sitekey: 'tg_site_…',
mode: 'managed', // managed | invisible | interactive
callback: (token) => {
// send { response: token } to your backend to verify
},
})Whichever path you use, the client half only produces a token — it never proves anything on its own. Verification always happens on your server.
React? Use the wrapper — one component that mounts the widget, keeps the hidden form field current, and cleans up on unmount:
import { TillGate } from '@tillstack/tillgate-react'
<form method="POST" action="/signup">
<TillGate sitekey="tg_site_…" onVerify={(token) => setToken(token)} />
<button type="submit">Create account</button>
</form>The widget is self-contained — Shadow DOM, an inline Web Worker, and no external requests except to tilldev.dev. It adapts to its host: an auto / light / dark theme, normal / compact / flexible sizes, a custom accent and radius, an interaction-only appearance that stays hidden until the visitor must act, and localized copy (language) with RTL and reduced-motion / forced-colors support built in.
Server verification
A token is worthless until your server checks it. POST the token and your secret key to the verify endpoint. The shape is plain and familiar — it maps onto whatever verification code you already have.
POST https://tilldev.dev/api/tillgate/siteverify
Content-Type: application/json
{
"secret": "tg_secret_…",
"response": "<token from the tillgate-response field>",
"remoteip": "<optional: the end-user's IP>"
}{
"success": true,
"hostname": "example.com",
"challenge_ts": "2026-07-09T10:15:04Z",
"error-codes": []
}success—trueonly when the token is valid, unexpired, unredeemed, and its proof-of-work checks out.hostname— the host the pass was issued for. Compare it to the request’s own host for defence in depth.challenge_ts— when the challenge was solved, so you can enforce your own freshness window.remoteip— optional. Pass the end-user’s IP and TillGate rejects a token being redeemed from a different address than solved it (ip-mismatch) — binding a pass to its origin, which Turnstile does not do.error-codes— empty on success; otherwise see error codes.
The request and response shapes are compatible with Cloudflare Turnstile’s siteverify, so migrating an existing integration is usually a change of URL and secret. Reserved test keys let you exercise both outcomes in CI without a live challenge:
| Test key | Effect |
|---|---|
tg_site_alwayspass | Site key — the widget always verifies (trivial challenge). |
tg_site_alwaysblock | Site key — the widget always renders the “access denied” state. |
tg_secret_alwayspass | Secret — /siteverify returns success:true for any response. |
tg_secret_alwaysfail | Secret — /siteverify returns invalid-input-response. |
tg_secret_alwaysspent | Secret — /siteverify returns timeout-or-duplicate (simulates a replay). |
true success as “no human proven” and fall back to a failed check — reject, rate-limit, or re-challenge. Never accept the token client-side alone.The three modes
Every mode runs the same proof-of-work and issues the same signed pass. What differs is how much the visitor sees. Set it on the site key or per widget.
| Mode | Behavior |
|---|---|
managed | Default. Usually runs silently and only escalates to a visible click-to-confirm step when the difficulty warrants it. |
invisible | No visible control. The proof-of-work runs in the background and the token is ready by submit time. |
interactive | Always renders an explicit control the visitor clicks to start the check. Best where the human step should be deliberate. |
Enforcement levels
Every site runs at one of three enforcement levels — a single setting under Shield → TillGate (or gate sites update --enforcement … from the CLI) that decides how hard the gate leans on a suspicious client. It’s independent of the visible mode: mode controls what the visitor sees, enforcement controls how far the gate will go.
| Level | Behavior |
|---|---|
standard | Default. Adaptive proof-of-work — a normal visitor gets your baseline difficulty, a risky one is escalated to a harder, memory-hard challenge, and a high-risk one is turned away. |
hardened | Everything standard does, plus definitive automation (headless browsers and automation frameworks that identify themselves) is denied a challenge outright rather than allowed to solve one. |
fortified | Highest assurance. A solvable puzzle is no longer enough — the visitor’s device must present a cryptographic attestation (a passkey-backed token). Clients that can’t attest are stopped. Fortified fails closed. |
A site can also rise to Fortified only while it’s under attack and fall back afterwards — turn on “escalate under attack” so the strongest posture is reserved for the moments that need it, without taxing everyday traffic.
Supplying a device attestation
On a Fortified site the widget reports that an attestation is required instead of handing over a puzzle. Provide an onAttestationRequired callback (hosted script: a global function named in data-attest-callback) that obtains an attestation token from your identity provider and returns it — the widget presents it and continues. With TillAuth as your provider, a visitor who has enrolled a passkey can produce one after a biometric prompt.
// Enforcement is set on the SITE (dashboard or CLI), not in the widget.
// The widget only needs to know how to fetch an attestation when asked:
render('#gate', {
sitekey: 'tg_site_…',
onAttestationRequired: async () => {
// run your device ceremony (e.g. a passkey prompt) and
// return the attestation token string, or null to give up
return await getDeviceAttestation()
},
callback: (token) => { /* verify server-side as usual */ },
})Omit onAttestationRequired and a Fortified site simply stops any client that can’t already present a valid attestation — the right default for an internal tool, the wrong one for a public sign-up.
Difficulty & adaptive defense
Difficulty is the number of leading zero bits the hash must have. Each extra bit roughly doubles the expected work, so it is an exponential dial: small increases raise the cost sharply.
You set a baseline per site key, and at the standard level TillGate scales the challenge to risk automatically: a normal visitor gets your baseline, while a request that looks like automation is handed a harder, memory-hard challenge — or turned away outright. Real people stay fast; bots pay. Hardened and Fortified go further still.
interactive mode for high-value actions (sign-up, checkout, password reset).Error codes
When success is false, the error-codes array explains why. The codes follow the familiar hyphenated convention:
| Code | Meaning |
|---|---|
missing-input-secret | No secret key was sent in the request. |
invalid-input-secret | The secret key is malformed or doesn’t match a project. |
missing-input-response | No response token was sent. |
invalid-input-response | The token is malformed, its signature is bad, or its proof-of-work doesn’t validate. |
timeout-or-duplicate | The token is past its TTL or has already been redeemed (replay). |
hostname-mismatch | The token’s bound hostname doesn’t match the site key’s project. |
ip-mismatch | A remoteip was supplied and it doesn’t match the IP that solved the challenge. |
rate-limited | Too many verifications for this secret in a short window. Back off and retry. |
internal-error | A transient error on our side. Retry the verification. |
WAF challenge
TillGate stands alone on any form, but it also complements the TillShield inline WAF. A rule whose mode is challenge can present TillGate instead of a bare 429: a request that trips a rate limit gets a chance to prove there’s a human behind it and continue, rather than being rejected outright. Same enforcement decision, softer edge.
TillGate is part of TillShield — one workspace, one login, one audit log. See the TillShield docs, or prefer the pitch? Read the product page.