Status: adopted 2026-08-04. Sign-up checks and Turnstile added 2026-10-11.
Signet protects account creation on its own hosted sign-up page with three checks, described below. It does not contain a bot-scoring network, JavaScript fingerprinting, or a Clerk-style bot-detection switch, and nothing below protects sign-in or password reset. For a public hosted deployment, put an abuse-aware edge in front of it and keep Signet's own deterministic controls enabled. This guide uses Cloudflare because that is the measured deployment shape; another edge is acceptable only if it preserves the same boundaries.
The hosted sign-up page
Every instance the signet binary runs has the first two checks. There is no setting to turn them off.
- A honeypot field. The form carries one extra text field a person never sees: it is moved off-screen and clipped (not only
display:none), hastabindex="-1",autocomplete="off"and sits in anaria-hiddenwrapper. A form filler that types into every field fills it. That submit gets exactly the answer a real sign-up gets: the same 303 to the same page, and on a copy that confirms email, the same "check your email" state. Nothing is created and no mail is sent. The one difference left: a copy that signs people in straight after sign-up sets a session cookie for a real account, and cannot for an account that does not exist. - A signed form token with a minimum fill time. The form carries a token, signed with the instance secret, that records when the form was served. A submit with no token, a token that does not verify, a token older than 30 minutes, or one sent back within 3 seconds gets the form back with one line, "Please try again.", and creates nothing. What was typed is put back (never the password). After a too-fast submit the same token goes back into the form, so a person who presses the button again a moment later gets through. Each token works for ONE submit: a token that passed is recorded as spent (a
verificationrow keyed by a digest of its random nonce, written only for a token that already verified, so a page view never writes), and posting it again gets "Please try again." A form shown again after a refusal carries a new token for the same served time. A bot that wants many accounts must load the page once per account and wait 3 seconds each time, inside the rate limit. - Cloudflare Turnstile, when the instance has keys. The page then shows Cloudflare's widget. This is the only script a hosted Signet page loads, and it comes from
challenges.cloudflare.com. The server sends each answer, with the secret, to Cloudflare's siteverify. The answer must be for the actionsign-upand the host in[server] base_url. A missing answer, a refused answer, or a Cloudflare that cannot be reached (network failure, timeout after 10 seconds, unreadable reply) refuses the sign-up with a plain line. It never lets the request through.
None of these needs JavaScript of Signet's own. None of them stops a bot that loads the page, waits, leaves the honeypot empty and solves Turnstile; the rate limiter and the edge still apply.
Where the Turnstile keys come from
In order, the first that applies wins:
[bot_protection] turnstile = "off"insignet.toml: Turnstile is off for this instance, whatever the environment holds. The honeypot and the token still run.[bot_protection] turnstile_site_keyandturnstile_secret(anenv:orfile:reference only) insignet.toml: this instance's own keys. Setting one without the other refuses boot.- The environment variables
SIGNET_TURNSTILE_SITE_KEYandSIGNET_TURNSTILE_SECRET: the host-wide default. If only one is set, the instance boots with Turnstile off and logs a warning that names both variables. - Nothing set: Turnstile is off.
One Turnstile widget covers every hosted domain: list the parent domains in the widget's hostname list (Cloudflare accepts their subdomains), for example kapable.run, kapable.ai, handsoff.run and signetauth.com.
On a Signet host (signet-instance.sh)
The host-wide default is two root-owned files in systemd's credential store. Every instance on the host reads them at start (ImportCredential=signet.turnstile.* in signet@.service), and the start wrapper exports them as SIGNET_TURNSTILE_SITE_KEY and SIGNET_TURNSTILE_SECRET:
install -d -m 0700 -o root -g root /etc/credstore
printf '%s' "$SITE_KEY" > /etc/credstore/signet.turnstile.site_key
printf '%s' "$TURNSTILE_SECRET" > /etc/credstore/signet.turnstile.secret
chmod 0600 /etc/credstore/signet.turnstile.*
# each instance picks them up at its next start or wake; to apply now:
systemctl restart 'signet@*.service'A host without the files runs every instance with Turnstile off: a credential that is not there imports nothing and never stops a unit from starting. Per instance:
signet-instance.sh turnstile <slug> offturns Turnstile off for that instance (SIGNET_TURNSTILE=offininstance.env, rendered asturnstile = "off");onremoves the line again.signet-instance.sh turnstile <slug> keystakesSITE_KEY=andSECRET=lines on standard input and gives the instance keys of its own (0600 files, loaded throughextra.conf), which win over the host-wide pair.keys-clearremoves them.signet-instance.sh turnstile <slug> json-offturns OFF the JSON check below for that instance (SIGNET_TURNSTILE_JSON_SIGN_UP=false);json-onremoves the line, back to the default. When the caller is your own server, prefer the platform credential bearer (below). It keeps the check on for everyone else.
Each change applies from the instance's next start.
The JSON sign-up route
Whenever an instance has Turnstile keys, POST {base_path}/sign-up/email requires a Turnstile answer too, sent in the x-captcha-response header: the header and the error codes of better-auth's captcha plugin. Without this a bot skipped every check on the hosted page by posting JSON. An app that calls the JSON route renders the widget itself (script https://challenges.cloudflare.com/turnstile/v0/api.js, the instance's site key) and sends its answer in that header.
A server that holds the instance's platform credential may send it as Authorization: Bearer <credential> instead of a Turnstile answer. This is for a service that creates accounts server-to-server, where no person and no widget take part. The credential must be live and must be able to create users through POST /admin/v1/users: a full or no-vault platform credential. The unlock profile and organization-scoped service tokens do not qualify.
The bearer skips the Turnstile step and nothing else. The origin check, the email policy, the email and password checks, the rate limit and the sign-up hooks all still run. The sign-up receipt (user.create in the admin audit log) names the credential's id, and Signet writes one INFO log line with that id and no token material. A missing, malformed, scoped or wrong bearer changes nothing. Signet judges the request as if no bearer came with it (MISSING_RESPONSE, never a 401), and the reply does not say how close the bearer came.
An instance whose app calls the JSON route without the widget, and cannot hold the platform credential, turns the check off explicitly: signet-instance.sh turnstile <slug> json-off, or [bot_protection] turnstile_json_sign_up = false, or SIGNET_TURNSTILE_JSON_SIGN_UP=false. Operators: prefer the platform credential bearer over json-off. json-off opens the JSON route to everyone. The bearer opens it only to the server that holds the credential. With no keys nothing changes: the JSON route is not checked and nothing is logged about it.
| answer | status | code |
|---|---|---|
| no header | 400 | MISSING_RESPONSE |
| Cloudflare refused it | 403 | VERIFICATION_FAILED |
| Cloudflare could not be reached | 500 | UNKNOWN_ERROR |
Each refusal carries two registers: human, a plain line a person may be shown ("We couldn't confirm you're a person. Please try again."), and message plus details for the agent: details.header (x-captcha-response), details.widget (the script and the public site key), and details.operatorFix (signet-instance.sh turnstile <slug> json-off). The MISSING_RESPONSE message also names the platform credential bearer, in the same words whether or not one was sent.
The JSON check does not bind the host: the answer was issued on the app's own page. Turnstile answers work once and expire after five minutes.
The layers solve different problems:
| layer | what it can do | what it cannot prove |
|---|---|---|
| hosted sign-up checks | turn away form fillers and bots that skip the page or submit at machine speed; with Turnstile, bots that cannot solve Cloudflare's challenge | anything about sign-in, reset, or a bot that behaves like a patient browser |
| Cloudflare edge | absorb volumetric traffic; apply WAF, rate, and bot signals before origin work | account identity, credential correctness, or Signet lock state |
| Signet rate limiter | bound requests per source IP and exact auth path; optional PostgreSQL storage coordinates processes | identify bots or absorb traffic before it reaches the origin |
| Signet account lockout | persistently bound failed password attempts per canonical identity across IPs/processes | distinguish a bot from a forgetful person |
| email/disposable policy | refuse configured identity-creation inputs | stop automation using acceptable addresses |
Edge protection is defense in depth. It does not replace [rate_limit], [lockout], [email_policy], or [disposable_email].
Multi-node Signet deployments should use [rate_limit] storage = "database" as described in the rate-limiting guide. That closes process-local quota splitting; it does not replace an edge because every refused request has still reached the origin and consumed a database operation.
Required Cloudflare boundary
- Proxy the authentication hostname in Cloudflare. Prevent a client from reaching the origin around Cloudflare: prefer Cloudflare Tunnel or a per-zone/per-hostname Authenticated Origin Pull; otherwise restrict the origin firewall to current Cloudflare address ranges. A proxied DNS record alone does not close an origin-IP bypass.
- At the last trusted proxy before Signet, accept
CF-Connecting-IPonly from an authenticated or allowlisted Cloudflare source and replaceX-Forwarded-Forwith that one value. Never append an incomingX-Forwarded-For. Signet uses the first value as its limiter identity; preserving a client-supplied first value lets the client choose fresh buckets or charge a victim's bucket. - Preserve the public
Host, set[server] base_urlto the outer HTTPS origin, and follow the scheme-header rules in the reverse-proxy guide. Bot controls do not repair a broken proxy trust boundary. - Verify from outside the origin network that the public origin works, direct-origin requests fail, and two different clients arrive in different Signet rate-limit buckets.
Cloudflare documents CF-Connecting-IP as the consistent, single-valued visitor address and recommends it over X-Forwarded-For at the origin. This is why the rewrite above is deliberate, not an interchangeable header preference.
Edge rules: start narrow
Match the configured hostname, HTTP method, and exact deployed [server] base_path; the default paths below therefore begin with /api/auth. Confirm real paths in Cloudflare analytics before enforcing a rule.
Start with observation, then rate-limit high-cost unauthenticated writes such as:
POST /api/auth/sign-in/email,/sign-in/username, and/sign-up/email;- password-reset and verification sends, including
/request-password-reset,/send-verification-email, and/email-otp/send-verification-otp; - OTP/code verification and anonymous identity creation; and
- the browser pages
/login,/sign-up, and/forgot-passwordwhen they are enabled.
Choose thresholds from observed legitimate traffic and NAT/mobile concentration, not by copying a sample number. Prefer counting failed responses when the Cloudflare plan and route response shape make that possible. Keep Signet's lower, deterministic endpoint budgets enabled: edge counters can reset or change independently and Signet's account lockout covers attacks distributed across IPs.
Roll out each rule in this order:
- log/observe;
- rate-limit or challenge a small browser-only cohort;
- verify password, OAuth callback, OTP, reset, mobile, API-client, monitoring, and IPv6 flows; and
- expand while watching Cloudflare events plus Signet
429,retry-after, andaccount.lockedtelemetry.
Do not turn on a blanket cache rule for the authentication hostname as part of this work. Bypass cache for state-changing auth APIs and callback routes; treat the public docs and OIDC discovery/JWKS documents as separate, deliberately reviewed cache policies.
Challenges are not transparent to API clients
Cloudflare's free Bot Fight Mode is not the default recommendation for an auth hostname. It covers the whole domain, cannot be skipped with WAF custom rules, and Cloudflare warns that it may challenge API or mobile traffic. A better-auth client expects JSON from auth routes; an injected HTML/JavaScript challenge is a protocol failure even when it blocks a bot.
Use narrowly scoped WAF/rate-limiting actions for JSON routes. Use Managed Challenge only where the request is an actual browser navigation/form flow and the complete client matrix has passed. If a deployment needs bot-product exceptions, use a Cloudflare product whose rules can be skipped for the affected API, callback, health, and monitoring paths; do not assume Bot Fight Mode exceptions exist.
Turnstile in Signet covers account creation only (the hosted page, and the JSON sign-up route unless an instance turned that off). A widget by itself is not protection: Cloudflare requires server-side siteverify validation, which Signet performs, and its answers are single-use and expire after five minutes. Sign-in, password reset and every other JSON route are not checked by Turnstile; they rely on the rate limiter, lockout and the edge.
Truthful claim boundary
Allowed:
Signet's hosted sign-up page has a honeypot, a minimum fill time and optional Cloudflare Turnstile. This deployment places Cloudflare WAF/rate controls in front of Signet and retains Signet's built-in rate limits, persistent account lockout, and email-admission policy.
Not allowed: "Signet has bot detection", "Signet scores bots", or any claim that Turnstile protects sign-in or reset.
Sources retrieved 2026-08-04
- Cloudflare Bot Fight Mode considerations and inability to skip rules:
https://developers.cloudflare.com/bots/get-started/bot-fight-mode/ - Cloudflare WAF rate-limiting guidance for login, credential-stuffing, OTP, and REST endpoints:
https://developers.cloudflare.com/waf/rate-limiting-rules/best-practices/ - Cloudflare visitor-IP header contract:
https://developers.cloudflare.com/fundamentals/reference/http-headers/ - Cloudflare origin protection options:
https://developers.cloudflare.com/fundamentals/security/protect-your-origin-server/ - Cloudflare Turnstile server-side validation contract:
https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ - better-auth captcha plugin (header, codes, 10-second deadline): vendored
packages/better-auth/src/plugins/captcha/(read 2026-10-11)