Signet docs
A better-auth‑compatible authentication server, sealed and certified. These docs are embedded in the binary — the same at 2am on an air-gapped host as they are anywhere else.
Quickstart
1. Write signet.toml with this instance's public origin and a Postgres DSN:
[server] listen = "0.0.0.0:3000" base_url = "https://auth.example.com" [database] adapter = "postgres" dsn = "env:SIGNET_DATABASE_URL"
2. Provide the secret and database URL out-of-band, then boot (migrations run on start):
export SIGNET_SECRET="$(head -c 32 /dev/urandom | base64)" export SIGNET_DATABASE_URL="postgres://user:pass@host/db" signet # add --config <path> to point elsewhere; --check validates and exits
3. Point any better-auth client at /api/auth on this origin. Confirm liveness at /health; browse the machine schema at /api/auth/open-api/generate-schema.
From your app
Any better-auth client integrates unchanged — point its baseURL at this instance. Plain JavaScript, no framework required:
import { createAuthClient } from "better-auth/client";
export const authClient = createAuthClient({
baseURL: "https://auth.example.com/api/auth",
});
Sign a user up, then sign them in:
await authClient.signUp.email({ email, password, name });
await authClient.signIn.email({ email, password });
Read the current session, and sign out:
const { data } = await authClient.getSession();
await authClient.signOut();
CLI preflight
The production signet binary also owns setup and diagnostics; no second CLI package is installed:
signet init --database postgres --base-url https://auth.example.com export SIGNET_SECRET="$(openssl rand -hex 32)" export SIGNET_DATABASE_URL="postgres://user:pass@host/db" signet doctor --offline signet doctor signet env pull --file .env.local
init writes a valid secret-free signet.toml and refuses an existing destination. Offline doctor checks config, licence posture, and delivery readiness; live doctor also drives /health and the generated OpenAPI schema, including its published server URL. env pull updates only SIGNET_AUTH_URL=https://auth.example.com/api/auth, preserves unrelated variables, and refuses duplicate assignments or symlink destinations. Use --stdout for one machine-clean assignment.
env:/file: references named by signet.toml; env pull never reads or writes them.User metadata
Every user can carry three JSON objects. publicMetadata is readable by ordinary clients but writable only through an admin surface; privateMetadata is readable and writable only through admin surfaces; unsafeMetadata is browser-readable and browser-writable. Email sign-up and authenticated /api/auth/update-user therefore accept only unsafeMetadata. A request that tries to set either protected bucket is rejected rather than ignored.
await authClient.signUp.email({
email,
password,
name,
unsafeMetadata: { onboarding: { step: 1 } },
});
await fetch("https://auth.example.com/api/auth/update-user", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ unsafeMetadata: { onboarding: { step: 2 } } }),
});
Admin create/update accepts all three fields inside data. Each value replaces the whole bucket; send {} to clear it or omit it to leave it unchanged. Values must be objects, and the three encoded buckets share an 8192-byte limit. Private metadata is omitted from sign-up, sign-in, session, ordinary user, and session-JWT user shapes. Full contract: docs/user-metadata.md in the distribution.
Organization SSO domain ownership
Email/domain and organization-slug SSO discovery use only a provider whose exact normalized domain has passed a DNS TXT ownership proof. An organization-linked provider carries one domain; register another provider for another domain so a proof cannot cover an unproved comma-separated value.
Both proof routes take {"providerId":"acme-saml"} and require the managing user's session. For an organization provider, only an owner or admin may call them. The request returns a stable seven-day txtRecordName and txtRecordValue; publish the exact value, then verify. A domain can belong to only one verified provider. Signet re-resolves a seven-day-old proof and suspends discovery if the exact TXT value disappears. Sign-in selector precedence is explicit providerId, then organizationSlug, then domain or the domain part of email. Email discovery failures are intentionally uniform.
member, once; a failed insert refuses the sign-in). Full contract: docs/organization-sso.md in the distribution.Password strength
[password] min_strength opts newly created passwords into zxcvbn score enforcement from 1 through 4. The default is 0 (disabled), preserving the length-only better-auth profile. Length checks still apply first.
A password-entry UI can request the exact instance decision and targeted feedback without storing or echoing the password:
const strength = await fetch("/api/auth/password-strength", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ password, userInputs: [email, name] }),
}).then(r => r.json());
// { score: 0..4, label, warning, suggestions, meetsPolicy, policy }
userInputs is optional and should contain account-specific words such as email or display name so reuse inside the password lowers its estimate. The sessionless endpoint enforces the same origin boundary as password writes, accepts at most 10 bounded inputs, and refuses passwords above this instance's configured maximum before running the estimator.Resend a verification link
An expired verification link returns TOKEN_EXPIRED; malformed or altered input returns INVALID_TOKEN. Keep those callback codes intact so the landing page can explain the cause. Request a fresh one with the same better-auth client:
await authClient.sendVerificationEmail({
email,
// Replace "/" with your own notice-aware landing page when you have one.
callbackURL: "/",
});
Without a client library, call the compatible endpoint directly:
curl -X POST "https://auth.example.com/api/auth/send-verification-email" \
-H "content-type: application/json" \
-H "origin: https://auth.example.com" \
--data '{"email":"reader@example.com","callbackURL":"/"}'
{"status":true} for an unknown address, an already-verified account, and an unverified account. A new message is sent only when the account exists and still needs verification. Tell the reader to check the address they entered and their spam folder. Without a configured delivery channel the endpoint names that operator action instead of pretending a message was sent.Custom email templates
SMTP subjects and plain-text bodies can be overridden per flow under [delivery.smtp.templates]. Omitted fields keep Signet's built-in copy; values may be inline TOML or env:NAME / file:/path references.
[delivery.smtp.templates]
verification_email_subject = "Verify {{email}}"
verification_email_body = """
Open this link to verify {{email}}:
{{url}}
"""
password_reset_subject = "Reset your password"
password_reset_body = "file:/etc/signet/mail/password-reset.txt"
Placeholders are strict and flow-specific. Link bodies must contain {{url}}, OTP bodies {{otp}}, and invitation bodies {{invite_id}}; a typo or a template that omits its action value stops boot and names the field and fix. Common email variables are {{email}} and {{recipient}}. Link bodies also expose {{token}}; OTP exposes {{otp_type}}; invitations expose {{organization_name}} and {{inviter_email}}. Action secrets ({{otp}}, {{url}}, {{token}}, {{invite_id}}) are body-only so they do not leak into notification previews or subject logs. Subjects are one line and all bodies are text/plain. Signed-webhook delivery remains structured JSON because the receiving application already owns its final rendering.
Migrating from Clerk
Export all users from Clerk's Dashboard Settings → User Exports, then validate the complete file against this instance's configured PostgreSQL without writing:
signet import --config /etc/signet/signet.toml \ --format clerk-csv --dry-run clerk-users.csv
Each line reports a row number, outcome, cause, and fix; each unconsumed Clerk column receives its own skip receipt. When failed=0, remove --dry-run. Re-running the same file is idempotent by normalized email.
signet import --config /etc/signet/signet.toml \ --format clerk-csv clerk-users.csv
The intake preserves Clerk id as user.id, maps the primary address's membership in verified_email_addresses / unverified_email_addresses to user.emailVerified, and writes password_digest to a credential account. The original bcrypt password works immediately through /api/auth/sign-in/email. A successful login transparently replaces bcrypt, Argon2, PBKDF2-PHC, or scrypt-PHC with Signet's unchanged better-auth-native scrypt default.
For converted input, --format clerk-json accepts an array with the same CSV keys. Generic --format csv requires email, password_hash, and email_verified; optional columns are external_id, name, image, created_at, and updated_at.
API keys
API keys are user-owned credentials compatible with better-auth 1.6.23's default apiKey() plugin. Create, update, delete, and list require the owning user's session; verification is sessionless so an application backend can authenticate the presented key.
const created = await fetch("/api/auth/api-key/create", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({ name: "deploy", prefix: "sk_prod_" }),
}).then(r => r.json());
// Send created.key to your secret store now. It is never returned again.
const result = await fetch("/api/auth/api-key/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ key: process.env.SIGNET_API_KEY }),
}).then(r => r.json());
The full key is returned only by create. Signet stores a SHA-256 base64url digest; list, update, verify, and delete never expose the stored digest or the raw secret. Defaults match the reference: 64 random ASCII letters after the optional prefix, six identifying starting characters, no expiry, enabled, and a per-key verification limit of 10 requests per 24 hours. expiresIn is seconds; update with expiresIn: null removes expiry. An exhausted non-refillable key is deleted, and disabled, expired, exhausted, permission-denied, or rate-limited verification returns {valid:false,error,key:null}.
Event webhooks
Configure [events] to receive user.created, account.locked, session.created, and session.revoked. Each JSON POST carries x-signet-timestamp (Unix seconds) and x-signet-signature (lowercase hex HMAC-SHA256). Verify the signature over the timestamp header, one ASCII dot, and the exact raw request body bytes:
events.secret must resolve to at least 32 characters. Generate one with openssl rand -hex 32, then provide it through an env: or file: reference.
signed_input = x-signet-timestamp + "." + raw_body expected = hex(HMAC-SHA256(events.secret, signed_input))
Read and preserve the raw body before JSON decoding. Reject the request when abs(now_unix_seconds - x-signet-timestamp) > 300, then compare the supplied and expected signatures in constant time. A changed timestamp or body must fail verification.
session.revoked data contains id, userId, and reason. The reason enum is expired, sign_out, revoke_session, revoke_all, revoke_other_sessions, password_change, multi_session_revoke, session_replaced, two_factor_disabled, user_ban, admin_revoke_all, or admin_revoke. Bulk revocations emit one event per deleted session. Concurrent revokers use an atomic delete-and-return operation, so only the database winner emits for a session.
event.id as the receiver's idempotency key. Signet does not claim an ordered queue or transactional outbox.account.locked data contains canonical email, lockedUntil, lockLevel, and lastIp. The email may have no user row: unknown addresses accumulate identical lock state to preserve the sign-in enumeration posture.
Abuse controls
Production defaults combine the per-IP rate limiter with persistent per-email lockout, deny-first email admission rules, and a bundled disposable-domain snapshot. The compatibility harness explicitly disables these Signet extensions, so the certified better-auth response surface remains unchanged.
Persistent lockout
[lockout] defaults to five failures in ten minutes. The first lock lasts 15 minutes, doubles for each consecutive lock, and is capped at 24 hours; the escalation level decays after 24 clean hours. State is stored by canonical email string rather than user id, so failures for unknown and real addresses follow the same path across every process and IP. A locked sign-in returns 429 ACCOUNT_LOCKED with standard retry-after plus the existing Signet x-retry-after alias. A correct password never bypasses a lock.
Username/password sign-in checks both the account's email key and a reserved username alias key. Unknown usernames accrue the same alias state, preventing the username plugin from becoming either a lockout bypass or a threshold-based existence oracle.
A completed password reset clears the lock immediately. Operators can clear any canonical address (including one with no user row) with POST /admin/v1/users/unlock, body {"email":"user@example.com"}; the action writes user.unlock to the admin audit log.
Allowlist and blocklist
[email_policy] accepts only exact user@example.com, apex example.com, and *.example.com. A wildcard matches subdomains only, never the apex. Precedence is explicit block, then non-empty allowlist, then disposable blocking, so a block survives an allow typo and a deliberate allow entry can carve through the disposable list.
Every matcher uses one canonical form: surrounding whitespace and a trailing domain dot are removed, case is folded, domain Unicode becomes IDNA punycode, and a local plus-tag is stripped. Provider-specific dot folding is deliberately not attempted; Gmail-style dot aliases remain an operator-visible residual.
Disposable domains
[disposable_email] enabled defaults true for identity creation. The binary bundles snapshot 2026-07-23; a listed domain also catches every subdomain. extra_deny adds local domains and allow supplies carve-outs. list_path replaces the bundled snapshot with a local one-domain-per-line file; an unreadable or malformed file stops boot and names its file and line rather than silently failing open.
account.locked; and any bundled disposable snapshot ages between releases, bounded by the stamped version plus the operator-owned list_path replacement.UI-kit boundary
Signet stays headless and does not ship signet-ui. React applications may use Better Auth UI through a stock better-auth browser client whose baseURL is this instance's full /api/auth auth path. The audited combination is better-auth 1.6.23 with @better-auth-ui/core, @better-auth-ui/react, and @better-auth-ui/heroui 1.6.43. Pin exact versions and regression-test application flows; third-party rendering, navigation, theming, and upgrades are application-owned.
Core email/password, social, reset/verification, session, profile, and account surfaces may use routes enabled by this instance. Enable a plugin component only when its complete endpoint set appears in /open-api/generate-schema or /llms-full.txt. A typed npm method does not prove that the deployed server implements it; Better Auth UI's passkey management, for example, calls list/add/delete methods this Signet build does not advertise.
@better-auth-ui/react/server recipes which call auth.api directly. Signet is a standalone HTTP service, not an in-process TypeScript Better Auth server. For SSR, render the auth shell client-side or build an application-owned HTTP adapter which forwards cookies correctly. Full contract: docs/ui-kits.md.JWT claim templates
The session-protected GET /api/auth/token keeps its better-auth-compatible public-user JWT when no query is supplied. Configure named [[jwt.templates]] allow-lists and request ?template=<name> when a relying party needs a different claim shape. Named tokens contain only configured claims plus server-owned iss, sub, iat, exp, and aud; audience is the sole registered-claim override.
Exact placeholders such as {{user.email}}, {{user.publicMetadata}}, and {{session.id}} preserve JSON types. Partial interpolation and unknown sources stop boot. Private metadata and session-token material have no placeholder. Each name, lifetime, claim size, nesting depth, duplicate, and protected claim is validated before the instance serves.
/jwks key, kid, full auth issuer, audience, and time claims. Firebase custom tokens are not supported because Firebase requires RS256 and a Google service-account issuer/subject. Full contract: docs/jwt-templates.md.Step-up reverification
Sensitive routes return SESSION_NOT_FRESH when the current session is older than [session] fresh_age. GET /api/auth/reverify reports fresh, verifiedAt, freshUntil, a correlation ID, the required factor level, and available strategies. For a non-MFA credential account, POST /api/auth/reverify/password with {"password":"..."} verifies the real password and marks this session fresh.
When verified two-factor exists, password step-up returns SECOND_FACTOR_REQUIRED. Complete the existing authenticated /two-factor/verify-totp, delivered send-otp + verify-otp, or single-use verify-backup-code route. A passkey assertion through the existing authentication ceremony rotates to a new fresh session. Five failed password or TOTP submissions in ten minutes produce a ten-minute session-bound REVERIFICATION_LOCKED with retry-after; delivered OTP retains its own five-attempt code budget.
createdAt or extends expiry. The random receipt ID is reusable during fresh_age; one-proof-per-action dynamic linking is application policy. Full contract: docs/reverification.md.End-to-end test sessions
The private, repository-owned @signet/testing package signs a fixture account in through the ordinary POST /api/auth/sign-in/email route, reads the signed credential exposed by the bearer plugin, and installs the real better-auth.session_token cookie for Playwright or Cypress. API tests can send the same credential as Authorization: Bearer. The package is path/workspace-installable from packages/signet-testing and is not published to npm.
TWO_FACTOR_REQUIRED; use a dedicated non-MFA fixture or drive the actual second-factor flow. Full contract: docs/testing.md.Bot protection at the edge
Signet does not contain bot scoring, browser fingerprinting, CAPTCHA, or a Clerk-style bot-detection switch. A public deployment should put WAF and rate controls at its trusted edge while retaining Signet's built-in per-IP/path rate limiter, persistent identity lockout, and email-admission policy.
For a Cloudflare-fronted origin, prevent direct-origin access and have the last trusted proxy replace X-Forwarded-For with the single CF-Connecting-IP value received from Cloudflare. Signet keys its limiter from the first forwarded value; appending a client-supplied chain gives the client control over its bucket.
Scope edge rules to the exact hostname, method, and configured /api/auth routes. Browser challenges are not transparent to better-auth JSON, mobile, callback, or monitoring clients. Cloudflare Bot Fight Mode covers the whole domain and cannot be skipped by custom WAF rules, so it is not a safe default for an auth/API hostname. Turnstile is not integrated; a widget alone does not protect the direct JSON routes and any custom use requires server-side validation.
docs/bot-protection.md in the distribution.Configuration reference
Generated from the server's config structs — every key it accepts, with types and defaults.
# Signet configuration reference
> GENERATED from the `*FileConfig` structs in `crates/signet/src/lib.rs` by
> `cargo run -p signet --bin gen-config-reference`. Do not edit by hand — a
> drift test fails if this file and the structs disagree.
Signet reads TOML config from `./signet.toml` (override with `--config <path>`
or `SIGNET_CONFIG`). Secrets belong in the environment, not the file: a value of
the form `env:VAR` or `file:/path` is resolved at load. Env overrides:
`SIGNET_SECRET`, `SIGNET_BASE_URL`, `SIGNET_LISTEN`, `SIGNET_DATABASE_URL`
(also selects the postgres adapter), `SIGNET_ADMIN_KEY`, `SIGNET_LICENSE_TOKEN`.
**Required** means the key must be set in this TOML file. A key that has an
env override (listed above) may be supplied that way instead, so it can read
`Required: no` here yet still be mandatory — set it in the file OR its env var.
## (top level)
Top-level keys (no section header).
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `secret` | `Option<String>` | no | The signing secret (≥ 32 chars). Set here or export `SIGNET_SECRET`; prefer `env:SIGNET_SECRET` or `file:/path` over a literal in the file. |
| `auto_sign_in` | `Option<bool>` | no | Sign a user in immediately after sign-up rather than requiring a separate sign-in. Default: engine default (false). |
## [server]
Network binding and this instance's public origin.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `listen` | `Option<String>` | no | Socket address to bind. Default: `127.0.0.1:3000`. Env: `SIGNET_LISTEN`. |
| `base_url` | `Option<String>` | no | This instance's public origin, e.g. `https://auth.example.com`. Set here or export `SIGNET_BASE_URL`. |
| `base_path` | `Option<String>` | no | Path prefix the better-auth API is served under. Default: `/api/auth`. Legal shape: one or more segments, each a `/` followed by one or more ASCII letters, digits, `-`, `.`, `_` or `~` — so it must start with `/`, must not end with `/`, and no segment may be empty, `.` or `..`. Anything else is REFUSED at load (the refusal names the defect, a corrective value and this rule), because the same string is simultaneously the route this server mounts and the prefix of the issuer it publishes: a value the router reads as a parameter or wildcard would mount the whole auth API under every path segment while the discovery document advertises the literal text. Set this when Signet is mashed up under a path of a larger site rather than served on its own host, e.g. `base_path = "/_auth"`. |
| `trusted_origins` | `Vec<String>` | no | Extra origins allowed for CORS/callback validation beyond `base_url`. |
| `trust_forwarded_headers` | `bool` | no | Believe one canonical `X-Forwarded-Proto: http\|https` value from the deployment edge. Default: false. Enable ONLY when Signet is unreachable except through a trusted proxy which deletes every inbound `Forwarded` and `X-Forwarded-*` header, then writes `X-Forwarded-Proto` itself. Missing, repeated, comma-joined, or invalid values fall back to the conservative both-schemes check; the standard `Forwarded` header is not read. See `docs/deploying-behind-a-reverse-proxy.md`. |
## [database]
Storage adapter — this section is required (the binary refuses to boot without it). For dev, set `adapter = "memory"` (data is lost on restart). For production, set `adapter = "postgres"` with `dsn` (or export `SIGNET_DATABASE_URL`, which selects postgres).
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `adapter` | `Option<String>` | no | Storage adapter: `"postgres"` (or `"memory"` for dev). Setting `SIGNET_DATABASE_URL` forces `postgres`. |
| `dsn` | `Option<String>` | no | PostgreSQL connection string. Prefer `env:SIGNET_DATABASE_URL`. |
| `migrate` | `Option<bool>` | no | Run embedded migrations on boot. Default: true. |
| `max_connections` | `Option<u32>` | no | Connection-pool ceiling. Default: adapter default. |
## [delivery]
How user-bound messages (verification codes, reset links) leave the instance. Omit for no delivery.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `mode` | `Option<String>` | no | Delivery channel: `"webhook"`, `"smtp"`, or `"none"`. Default: none. |
| `dead_letter` | `bool` | no | Retain messages that fail delivery in a dead-letter store for later replay from the admin surface. Default: false. |
## [delivery.webhook]
Signed-JSON webhook delivery target (when `mode = "webhook"`).
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `url` | `Option<String>` | no | Destination URL for signed-JSON delivery POSTs. |
| `secret` | `Option<String>` | no | HMAC-SHA256 signing secret for `{x-signet-timestamp}.{raw_body}`; the lowercase hex digest is sent as `x-signet-signature`. Prefer an `env:`/`file:` ref. Must resolve to at least 32 characters. Generate one with: `openssl rand -hex 32`. |
## [delivery.smtp]
SMTP delivery (when `mode = "smtp"`).
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `host` | `Option<String>` | no | SMTP server hostname. |
| `port` | `Option<u16>` | no | SMTP server port (e.g. 587). |
| `username` | `Option<String>` | no | SMTP auth username, if the server requires it. |
| `password` | `Option<String>` | no | SMTP auth password; prefer an `env:`/`file:` ref. |
| `from` | `Option<String>` | no | Envelope `From` address. |
## [delivery.smtp.templates]
Optional plain-text SMTP subjects and bodies. Placeholders are strict: an unknown name or a body missing its flow's action value stops boot with the field and corrective action. Signed webhook delivery stays structured JSON so its receiver owns rendering.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `email_otp_subject` | `Option<String>` | no | Email-OTP subject. Variables: `{{email}}`, `{{recipient}}`, `{{otp_type}}`. |
| `email_otp_body` | `Option<String>` | no | Email-OTP body. Variables: `{{email}}`, `{{recipient}}`, `{{otp}}`, `{{otp_type}}`; must contain `{{otp}}`. |
| `magic_link_subject` | `Option<String>` | no | Magic-link subject. Variables: `{{email}}`, `{{recipient}}`. |
| `magic_link_body` | `Option<String>` | no | Magic-link body. Variables: `{{email}}`, `{{recipient}}`, `{{url}}`, `{{token}}`; must contain `{{url}}`. |
| `verification_email_subject` | `Option<String>` | no | Verification-email subject. Variables: `{{email}}`, `{{recipient}}`. |
| `verification_email_body` | `Option<String>` | no | Verification-email body. Variables: `{{email}}`, `{{recipient}}`, `{{url}}`, `{{token}}`; must contain `{{url}}`. |
| `password_reset_subject` | `Option<String>` | no | Password-reset subject. Variables: `{{email}}`, `{{recipient}}`. |
| `password_reset_body` | `Option<String>` | no | Password-reset body. Variables: `{{email}}`, `{{recipient}}`, `{{url}}`, `{{token}}`; must contain `{{url}}`. |
| `invitation_subject` | `Option<String>` | no | Invitation subject. Variables: `{{email}}`, `{{recipient}}`, `{{organization_name}}`, `{{inviter_email}}`. |
| `invitation_body` | `Option<String>` | no | Invitation body. Variables: `{{email}}`, `{{recipient}}`, `{{invite_id}}`, `{{organization_name}}`, `{{inviter_email}}`; must contain `{{invite_id}}`. |
## [events]
Outbound signed **event webhooks** (`user.created`, `session.created`, `session.revoked`) — the integration seam an app subscribes to. Distinct from `[delivery]` (which sends user-bound messages). Omit for no event emission.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `url` | `Option<String>` | no | App endpoint that receives signed event POSTs (`user.created`, `session.created`, `session.revoked`). |
| `secret` | `Option<String>` | no | HMAC-SHA256 signing secret for `{x-signet-timestamp}.{raw_body}`; the lowercase hex digest is sent as `x-signet-signature`. Prefer an `env:`/`file:` ref. Must resolve to at least 32 characters. Generate one with: `openssl rand -hex 32`. |
| `dead_letter` | `bool` | no | Retain events that fail delivery in the `eventDeadLetter` store for later replay from the admin surface. Default: false. |
## [session]
Session lifetime knobs (seconds).
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `expires_in` | `Option<i64>` | no | Session lifetime in seconds. Default: engine default (7 days). |
| `update_age` | `Option<i64>` | no | Seconds before a session's expiry is refreshed on use. Default: engine default. |
| `fresh_age` | `Option<i64>` | no | Seconds a session is considered "fresh" for sensitive actions. Default: engine default. |
## [jwt]
Session JWT lifetime and named declarative claim templates. The untemplated `GET /token` remains the better-auth-compatible default; select a named shape with `?template=<name>`.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `default_expires_in` | `Option<i64>` | no | Lifetime in seconds for the ordinary, untemplated session JWT. Default: 900. |
## [[jwt.templates]]
Named claim allow-list. Registered issuer/subject/time claims stay server-owned, `audience` controls `aud`, and exact placeholders can read only public user/session fields — never private metadata or the session token.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `name` | `String` | yes | URL-safe selector (ASCII letters/digits plus `.`, `_`, `-`; max 64 bytes). |
| `audience` | `Option<String>` | no | Optional `aud` value. Omit to use `server.base_url`. |
| `expires_in` | `Option<i64>` | no | Token lifetime in seconds (60..=86400). Default: `[jwt].default_expires_in`. |
| `claims` | `serde_json::Map<String, serde_json::Value>` | no | JSON-like static claims and exact public placeholders such as `{{user.email}}`, `{{user.publicMetadata}}`, or `{{session.id}}`. |
## [password]
Password length, optional zxcvbn strength enforcement, and scrypt cost.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `min` | `Option<usize>` | no | Minimum password length. Default: 8. |
| `max` | `Option<usize>` | no | Maximum password length. Default: 128. |
| `min_strength` | `Option<u8>` | no | Minimum zxcvbn strength score (0 disables; accepted range 0-4). Default: 0. |
| `scrypt_concurrency` | `Option<usize>` | no | scrypt parallelism factor (must be ≥ 1). Default: 4. |
## [rate_limit]
Built-in rate limiter. On by default.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `enabled` | `Option<bool>` | no | Enable the built-in rate limiter. Default: true. |
| `storage` | `Option<String>` | no | Counter storage: `"memory"` (default) or `"database"` for a shared PostgreSQL quota across processes. |
| `window` | `Option<i64>` | no | Default window in seconds. Default: 10. |
| `max` | `Option<i64>` | no | Default max requests per window. Default: 100. |
## [[rate_limit.rules]]
Per-path override rules (repeat the block per rule).
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `path` | `String` | yes | The path (relative to `base_path`) this rule applies to, e.g. `/sign-in/email`. |
| `window` | `i64` | yes | Window in seconds for this rule. |
| `max` | `i64` | yes | Max requests per window for this rule. |
## [lockout]
Persistent email-keyed credential lockout with capped exponential backoff. A successful password reset or the admin unlock action clears the row.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `enabled` | `Option<bool>` | no | Enable persistent email-keyed account lockout. Default: true. |
| `max_failures` | `Option<i64>` | no | Failed credential attempts allowed in one window. Default: 5. |
| `window` | `Option<i64>` | no | Failure-counting window in seconds. Default: 600 (10 minutes). |
| `lock_duration` | `Option<i64>` | no | First lock duration in seconds. Default: 900 (15 minutes). |
| `backoff_multiplier` | `Option<i64>` | no | Multiplier applied for each consecutive lock. Default: 2. |
| `max_lock_duration` | `Option<i64>` | no | Backoff ceiling in seconds. Default: 86400 (24 hours). |
| `lock_level_decay` | `Option<i64>` | no | Clean period before escalation returns to level zero, in seconds. Default: 86400. |
## [email_policy]
Deny-first email admission policy. Entry forms are exact `user@example.com`, apex `example.com`, or `*.example.com` (subdomains only, not the apex).
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `allow` | `Vec<String>` | no | Admission rules: exact emails, apex domains, or `*.example.com` (subdomains only). |
| `block` | `Vec<String>` | no | Denial rules in the same forms. Block always wins over allow. |
## [disposable_email]
Disposable-domain blocking for identity creation. The bundled versioned snapshot is used unless `list_path` replaces it; listed parents match all subdomains.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `enabled` | `Option<bool>` | no | Block disposable domains on identity creation. Default: true. |
| `list_path` | `Option<String>` | no | Replace the bundled snapshot with this local file (one domain per line). |
| `extra_deny` | `Vec<String>` | no | Extra parent domains to deny in addition to the selected snapshot. |
| `allow` | `Vec<String>` | no | Exact/domain/wildcard carve-outs applied within disposable matching. |
## [plugins]
Optional engine plugins.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `oauth_proxy` | `Option<bool>` | no | Enable the OAuth proxy plugin. Default: engine default. |
| `haveibeenpwned` | `Option<bool>` | no | Enable the Have I Been Pwned breached-password check. Default: engine default. |
| `hibp_range_endpoint` | `Option<String>` | no | Override the HIBP range API endpoint (for a self-hosted mirror). |
## [oauth]
Where the OAuth2 authorization flow sends a browser when it needs the human. Both defaults point at pages **this binary serves**, so a fresh install can complete an authorization in a browser with no host app; set either to a path of your own and Signet stops serving its built-in page there. Neither key adds a route to the `/oauth2/*` API — `/oauth2/consent` stays POST-only.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `login_page` | `Option<String>` | no | Where `/oauth2/authorize` sends an unauthenticated browser. Default: `/login`, served by this instance. Set it to your own sign-in page and Signet serves nothing at `/login`. |
| `consent_page` | `Option<String>` | no | Where `/oauth2/authorize` sends a browser that must grant consent. Default: `/consent`, served by this instance. Set it to your own page and Signet serves nothing at `/consent`; that page must POST `{accept, scope, oauth_query}` to `{base_path}/oauth2/consent`. |
## [pages]
The account pages that are NOT part of the authorization flow — sign-up and password reset. Separate from `[oauth]` because `/oauth2/authorize` never sends a browser to either one. Every default points at a page **this binary serves**, so a fresh install is not a dead end for someone without an account or with a forgotten password; set a key to a path of your own and Signet stops serving its built-in page there AND stops linking to it. None of these adds a route to the JSON API — the pages call the same `{base_path}` routes any client would.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `sign_up_page` | `Option<String>` | no | Where the built-in login page links someone with no account. Default: `/sign-up`, served by this instance. Set it to your own page and Signet serves nothing at `/sign-up` and stops linking to it. |
| `forgot_password_page` | `Option<String>` | no | The "email me a reset link" form. Default: `/forgot-password`, served by this instance. Set it to your own page and Signet serves nothing there; that page must POST `{email, redirectTo}` to `{base_path}/request-password-reset`. |
| `reset_password_page` | `Option<String>` | no | Where the emailed reset link lands, and the value the built-in forgot-password form passes as `redirectTo` — so setting this re-points the emailed link at your page. Default: `/reset-password`, served by this instance. Your page receives `?token=…` (or `?error=…`) and must POST `{token, newPassword}` to `{base_path}/reset-password`. |
## [[social_providers]]
OAuth social providers (repeat the block per provider).
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `id` | `String` | yes | Provider id, e.g. `google` or `github` (built-in defaults), or a custom id. |
| `client_id` | `String` | yes | OAuth client id. |
| `client_secret` | `String` | yes | OAuth client secret; prefer an `env:`/`file:` ref. |
| `authorization_endpoint` | `Option<String>` | no | Authorization endpoint. Required for custom providers; defaulted for google/github. |
| `token_endpoint` | `Option<String>` | no | Token endpoint. Required for custom providers; defaulted for google/github. |
| `user_endpoint` | `Option<String>` | no | Userinfo endpoint. Required for custom providers; defaulted for google/github. |
| `scopes` | `Option<Vec<String>>` | no | OAuth scopes to request. Defaulted for google/github. |
| `pkce` | `Option<bool>` | no | Use PKCE. Defaulted for google/github. |
## [admin]
The instance-scoped admin surface (`/admin/v1` + the `/admin` dashboard). Off unless `enabled = true`.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `enabled` | `Option<bool>` | no | Turn the admin surface on. Default: false (no `/admin/v1`, no `/admin` dashboard — an unconfigured instance answers those paths with 404). |
| `key` | `Option<String>` | no | The admin key (≥ 32 chars). Supply out-of-band via `SIGNET_ADMIN_KEY` (keeps the secret out of the file); required once `enabled = true`. |
## [admin_plugin]
The better-auth-compatible, end-user-session-authenticated admin plugin. This is separate from the Bearer-key `[admin]` operator surface.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `default_role` | `Option<String>` | no | Role assigned by admin create-user when no role is requested. Default: user. |
| `admin_roles` | `Option<Vec<String>>` | no | Roles with the built-in admin permissions. Default: ["admin"]. |
| `admin_user_ids` | `Option<Vec<String>>` | no | User ids that receive every admin permission regardless of role. Default: []. |
| `roles` | `Option<Vec<String>>` | no | Optional role allow-list for create-user and set-role. Omit to accept any string. |
| `impersonation_session_duration` | `Option<i64>` | no | Maximum impersonation-session lifetime in seconds. Default: 3600; range: 60..=86400. |
| `allow_impersonating_admins` | `Option<bool>` | no | Permit impersonating users whose role/id marks them as admins. Default: false. |
## [siwe]
Sign-In With Ethereum. On by default with cryptographically random, persisted single-use nonces and local ERC-191 recovery for externally owned accounts. EIP-1271 contract wallets additionally need the relevant chain endpoint in `rpc_urls`; that endpoint is an authentication trust root for contract wallets on its chain, and its value supports `env:` / `file:` secret references. Set `enabled = false` to remove `/siwe/*` entirely.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `enabled` | `Option<bool>` | no | Register the `/siwe/*` routes. Default: true. Set false to remove them. |
| `rpc_urls` | `HashMap<String, String>` | no | Ethereum JSON-RPC URLs keyed by decimal chain ID, used only for EIP-1271 contract-wallet verification; EOA signatures are verified locally. URL values accept `env:NAME` and `file:/path` refs so provider credentials do not need to appear in TOML. Each endpoint is an authentication trust root for contract wallets on its chain, so use only a trusted provider. Example: `{ "1" = "env:ETH_RPC_URL" }`. |
## [mcp]
The MCP plugin's OAuth front door. Its one key governs RFC 7591 unauthenticated dynamic client registration on `/mcp/register`, which is OFF by default: the route mints a confidential client secret for an anonymous caller and stores no owning principal, so the resulting client is invisible to every management route and removable only through direct database access. Turn it on if the MCP clients you serve require dynamic registration — many do — and accept that exposure deliberately.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `registration_enabled` | `Option<bool>` | no | Serve RFC 7591 unauthenticated dynamic client registration on `/mcp/register`. Default: false — the route hands a confidential client secret to any stranger and records no owning principal, so the row it writes cannot be listed or revoked through any management route. Set true when the MCP clients you serve require dynamic registration. |
## [ssh_ca]
A dedicated Ed25519 OpenSSH **user** certificate authority for one explicitly configured, pre-existing Unix account. Disabled by default. Signet never generates the trust root, creates Unix users, signs host certificates, or provides instant revocation; certificate lifetime is the revocation bound.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `enabled` | `Option<bool>` | no | Enable OpenSSH user-certificate signing. Default: false. Enabling requires `private_key`; Signet never generates a CA key at boot. |
| `private_key` | `Option<String>` | no | Existing unencrypted Ed25519 OpenSSH private key. For key-custody safety this accepts only `file:/path` or `env:NAME`, never an inline literal. A file must be regular, owned by the Signet process user, and mode 0400 or 0600. |
| `principal` | `Option<String>` | no | The one pre-existing Unix account name this first slice may certify. It must match `[a-z_][a-z0-9_-]*` and is never inferred from email. |
| `default_ttl_seconds` | `Option<u64>` | no | TTL used when a request omits one. Default: 600 seconds. |
| `maximum_ttl_seconds` | `Option<u64>` | no | Hard request TTL cap. Default: 3600; maximum accepted value: 86400. |
| `clock_skew_seconds` | `Option<u64>` | no | Backdate `valid_after` for bounded host/workstation clock disagreement. Default: 60 seconds; maximum accepted value: 300. |
| `permit_pty` | `Option<bool>` | no | Add OpenSSH's `permit-pty` extension. Default: false (explicit opt-in). |
| `permit_agent_forwarding` | `Option<bool>` | no | Add `permit-agent-forwarding`. Default: false. |
| `permit_port_forwarding` | `Option<bool>` | no | Add `permit-port-forwarding`. Default: false. |
| `permit_user_rc` | `Option<bool>` | no | Add `permit-user-rc`. Default: false. |
| `permit_x11_forwarding` | `Option<bool>` | no | Add `permit-X11-forwarding`. Default: false. |
## [[tokens.kind]]
One registered credential class, read by `POST {base_path}/tokens/introspect` (repeat the block per kind). Every block also carries a required `verify` table, documented under `[tokens.kind.verify]` below. **Omit the whole `[tokens]` section** and the instance registers the four classes Signet mints — `session`, `api-key`, `oauth-access`, `oauth-refresh` — described exactly as it mints them. Declaring any block REPLACES that set rather than extending it, and boot warns naming every class the file dropped. This release registers only classes Signet mints itself, so `prefix`, `format`, `storage`, `lifetime` and `revocable` are ASSERTIONS about this build: a declaration that disagrees with what Signet really mints and stores is refused at boot, naming both values. In particular a built-in class may not be given a `prefix` — none of them is minted with one, and dispatch would then miss every real credential.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `name` | `String` | yes | The registry-unique kind name. Reported on the wire as `kind` and selected by `token_type_hint`. Operator-owned: rename a class and stock RFC 7662 clients keep working, because `access_token`/`refresh_token` resolve through the class, not the name. |
| `storage` | `String` | yes | What this instance holds at rest: `hashed` or `none`. REQUIRED, and never defaulted — design doc §1 makes an operator storing a bearer secret in plaintext say so out loud. Signet's own `session` kind is `none`. |
| `lifetime` | `String` | yes | When credentials of this kind stop being valid: `per-credential` (the credential carries its own expiry — the only truthful answer for every class Signet mints), `none` for a kind that never expires, or a duration such as `90d`, `24h`, `15m`, `3600s`. REQUIRED: "unset" must never silently mean "forever". |
| `prefix` | `Option<String>` | no | The dispatch prefix credentials of this kind begin with. Omit it for every built-in class — none of them is minted with one, and declaring one is refused rather than accepted into a registry that would then dispatch on a prefix no credential carries. |
| `format` | `Option<String>` | no | Wire format: `opaque` or `jwt`. Default: the class's real format. |
| `revocable` | `Option<bool>` | no | Whether this instance can kill the credential. Default: the class's real answer. `false` is a first-class visible state ("seen, cannot revoke"), not an omission. |
| `introspectable` | `Option<bool>` | no | Whether this instance may describe credentials of this kind at all. Default: true. Operator-owned, and it governs BOTH token doors: `POST {base_path}/tokens/introspect` draws a typed refusal naming the kind rather than a silent `active: false`, and `GET {base_path}/tokens` reports the kind with `listed: false` and a reason rather than an empty count — which would assert the subject holds none of them. |
| `audience` | `Option<Vec<String>>` | no | The resource servers credentials of this kind are FOR, echoed in the introspection envelope's `audience`. Default: empty, which is a stated answer rather than an omission. Operator-owned. |
| `entropy_bytes` | `Option<u64>` | no | Design doc §1's minting parameter. Refused for every class Signet mints: the registry describes credentials, it does not make them, and the real generators do not draw whole bytes. |
| `mint_requires` | `Option<String>` | no | Design doc §1's mint-authority key. Refused for every class Signet mints: authority over minting belongs to the route that mints, and a registry value that gated nothing would read as a gate that exists. |
## [tokens.kind.verify]
What verifies a credential of this kind. Usually written inline: `verify = { via = "builtin", class = "session" }`.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `via` | `String` | yes | How the credential is checked. This release accepts only `builtin` (verified in-process against Signet's own storage). Delegates to a locally-reachable HTTP introspection endpoint, for credentials Signet never mints, are a later increment of the universal token system. |
| `class` | `Option<String>` | no | Which built-in credential class verifies this kind, when `via = "builtin"`: `session`, `api-key`, `oauth-access` or `oauth-refresh`. |
## [license]
Warrant licence verification. The check is **entirely offline** — an Ed25519 signature check against an issuer public key baked into the binary at build time, plus an expiry comparison. Signet never contacts a licence server, so an air-gapped instance verifies exactly as a connected one does. Omit the section to run unlicensed. Licence state is shown on the admin console's Instance Receipt (`/admin`); it is deliberately absent from the public `/certification` surface.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `token` | `Option<String>` | no | The signed Warrant licence token (`warrant.v1.…`), exactly as returned by activation. Prefer `env:SIGNET_LICENSE_TOKEN` or `file:/path` over a literal; `SIGNET_LICENSE_TOKEN` also works on its own. Omit to run unlicensed. |
| `enforce` | `Option<bool>` | no | Refuse to boot when the licence is absent, expired, or unverifiable. Default: false — an unlicensed instance logs a warning and serves. |
Rate limits
Per-client rate limiting is on by default ([rate_limit] enabled defaults true). Each client is keyed by source IP and request path; exceeding a limit returns 429 with an x-retry-after header carrying the seconds until the window resets.
Tune the default with [rate_limit] window and max, or override any path with a [[rate_limit.rules]] block (exact path or a * wildcard). Custom rules take precedence over the built-in per-path limits above, which in turn override the default.
storage = "memory" is the default and keeps counters inside one process. Set storage = "database" with the PostgreSQL adapter to coordinate one atomic quota across every Signet process sharing that schema; migration 0002_rate_limit.sql creates the table. Database failures return a generic 500 rather than letting requests bypass the limiter.
docs/rate-limiting.md and docs/deploying-behind-a-reverse-proxy.md.Social sign-in providers
Add one [[social_providers]] block per OAuth/OIDC provider. google and github carry built-in endpoint, scope, and PKCE defaults; any other OIDC-compatible provider works by supplying its endpoints yourself.
google, github, or a custom idenv:VAR ref, never inlineA worked example — Google, with the secret kept in the environment:
[[social_providers]] id = "google" client_id = "env:GOOGLE_CLIENT_ID" client_secret = "env:GOOGLE_CLIENT_SECRET" authorization_endpoint = "https://accounts.google.com/o/oauth2/v2/auth" token_endpoint = "https://oauth2.googleapis.com/token" scopes = ["email", "profile", "openid"] pkce = true
Because google ships those defaults, the endpoint, scope, and PKCE lines above are optional — id, client_id, and client_secret alone are enough. A custom provider supplies its own authorization_endpoint, token_endpoint, and user_endpoint.
Operating the instance
Signet's only bespoke data command is the direct-database import intake documented above. Lifecycle operations use your own PostgreSQL and standard tooling, because your data is yours — that is the sovereignty guarantee, not a feature to buy back.
Upgrade
Replace the binary and restart the process. Embedded migrations run automatically on boot whenever [database] migrate is true (the default), so the schema moves forward with the binary. Users, sessions, and accounts live in PostgreSQL, so they survive the swap; a graceful shutdown (SIGTERM / Ctrl‑C) lets in-flight requests finish first. Validate the new build against your config before cutting over:
signet --config /etc/signet/signet.toml --check # prints "config OK" and exits # then swap the binary and restart the service
Backup
All durable state is in the PostgreSQL database named by [database] dsn. Back it up with pg_dump; there is no separate Signet backup command to run or trust.
pg_dump "$SIGNET_DATABASE_URL" --format=custom --file signet-$(date +%F).dump
adapter = "memory" backend has no persistence and nothing to back up. Take backups on the PostgreSQL side against a running database.Restore
Restore the dump into a database, point [database] dsn at it, and boot. Migrations are idempotent: already-applied ones are skipped, so a restored database that is already at the current schema needs no extra step.
pg_restore --clean --if-exists --dbname "$SIGNET_DATABASE_URL" signet-2026-01-01.dump signet --config /etc/signet/signet.toml # migrations reconcile on boot
Export
The binary ships no export subcommand, and none is needed: your data never leaves your PostgreSQL. Use pg_dump for a complete restorable archive. For a portable handoff, export both users and accounts — password hashes live in account.password, not in user.
umask 077
pg_dump "$SIGNET_DATABASE_URL" --format=custom --file signet.dump
psql "$SIGNET_DATABASE_URL" --csv -c '
SELECT "id", "name", "email", "emailVerified", "image", "createdAt", "updatedAt"
FROM "user" ORDER BY "createdAt", "id"
' > signet-users.csv
psql "$SIGNET_DATABASE_URL" --csv -c '
SELECT "id", "userId", "accountId", "providerId", "password",
"accessToken", "refreshToken", "idToken", "scope", "createdAt", "updatedAt"
FROM "account" ORDER BY "userId", "providerId", "id"
' > signet-accounts.csv
A joined export uses FROM "user" AS u LEFT JOIN "account" AS a ON a."userId" = u."id"; keep the left join so passwordless/social-only users remain visible. Count user, all account rows, and credential accounts before and after migration. Treat every file as credential material: password hashes and OAuth tokens require restrictive permissions, encryption at rest, and authenticated transfer.
Certification & support
This instance's compatibility receipt: /certification (JSON). Machine on-ramp for AI agents: /llms.txt.