TILLGATE · OVERVIEW

Prove there’s a human.

GA

TillGate 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.

01Mental model

Three moving parts

  1. 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.
  2. A signed pass token. On success the widget writes a short-lived, signed token into a form field named tillgate-response and calls your data-callback. The token is bound to your hostname and carries a short TTL.
  3. 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.

02Keys

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.
Server only
Never put the tg_secret_… key in client code, a bundle, or a repository. It belongs only in your backend’s /siteverify call.
03Quick start

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.

html
<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).

04Quick start

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.

bash
npm i @tillstack/tillgate
gate.ts
import { 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:

SignupForm.tsx
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.

05Verify

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.

http
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>"
}
200 OK
{
  "success":      true,
  "hostname":     "example.com",
  "challenge_ts": "2026-07-09T10:15:04Z",
  "error-codes":  []
}
  • successtrue only 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 keyEffect
tg_site_alwayspassSite key — the widget always verifies (trivial challenge).
tg_site_alwaysblockSite key — the widget always renders the “access denied” state.
tg_secret_alwayspassSecret — /siteverify returns success:true for any response.
tg_secret_alwaysfailSecret — /siteverify returns invalid-input-response.
tg_secret_alwaysspentSecret — /siteverify returns timeout-or-duplicate (simulates a replay).
Server side
Treat a non-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.
06Modes

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.

ModeBehavior
managedDefault. Usually runs silently and only escalates to a visible click-to-confirm step when the difficulty warrants it.
invisibleNo visible control. The proof-of-work runs in the background and the token is ready by submit time.
interactiveAlways renders an explicit control the visitor clicks to start the check. Best where the human step should be deliberate.
07Enforcement

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.

LevelBehavior
standardDefault. 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.
hardenedEverything standard does, plus definitive automation (headless browsers and automation frameworks that identify themselves) is denied a challenge outright rather than allowed to solve one.
fortifiedHighest 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.

Fortified is strict
Because Fortified requires an attestation, enable it only where your visitors can supply one (below). On a public form with no attestation path, Fortified will stop legitimate visitors too — that is the trade for its assurance. It suits locked-down internal tools and your highest-value actions.

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.

gate.ts
// 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.

08Tuning

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.

Tune for your users
Keep the baseline modest on consumer-facing forms so you don’t tax slower devices or metered connections. Reserve higher difficulty and interactive mode for high-value actions (sign-up, checkout, password reset).
09Reference

Error codes

When success is false, the error-codes array explains why. The codes follow the familiar hyphenated convention:

CodeMeaning
missing-input-secretNo secret key was sent in the request.
invalid-input-secretThe secret key is malformed or doesn’t match a project.
missing-input-responseNo response token was sent.
invalid-input-responseThe token is malformed, its signature is bad, or its proof-of-work doesn’t validate.
timeout-or-duplicateThe token is past its TTL or has already been redeemed (replay).
hostname-mismatchThe token’s bound hostname doesn’t match the site key’s project.
ip-mismatchA remoteip was supplied and it doesn’t match the IP that solved the challenge.
rate-limitedToo many verifications for this secret in a short window. Back off and retry.
internal-errorA transient error on our side. Retry the verification.
10Integration

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.