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();
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.
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.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`. |
| `trusted_origins` | `Vec<String>` | no | Extra origins allowed for CORS/callback validation beyond `base_url`. |
## [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. |
## [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. |
## [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. |
## [password]
Password policy 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. |
| `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. |
| `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). |
## [[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. |
## [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
In-process 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.
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.