Signet

Signet docs

Point the stock better-auth client at this instance’s API base path — that is the integration. These docs are embedded in the binary, so they are 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.

Signet's instance configuration and secret stores are operator-owned: the CLI has no hosted vault from which it could pull database, admin, signing, or delivery secrets. Those stay in the 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.

Request proofPOST /api/auth/sso/request-domain-verification
Verify DNSPOST /api/auth/sso/verify-domain

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.

This proof enables discovery; it does not yet force password/reset traffic through SSO, so do not describe it as downgrade-resistant SSO policy. SSO sign-in DOES create the organization membership for an org-linked provider (role 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 }
Send the password in the JSON POST body, never a query string. 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":"/"}'
With mail delivery configured, the response body is {"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.

Clerk's public export page confirms that Dashboard CSV exports contain hashes but does not publish a versioned exhaustive header schema. Signet follows the exact keys in Better Auth's Clerk migration guide and makes unknown columns visible. Passwordless/social-only rows fail instead of creating a false credential account; phones, TOTP, OAuth grants, and active sessions require separate migration review.

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.

CreatePOST /api/auth/api-key/create
VerifyPOST /api/auth/api-key/verify
UpdatePOST /api/auth/api-key/update
RevokePOST /api/auth/api-key/delete
ListGET /api/auth/api-key/list
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}.

API keys ship in the binary. There is no Clerk-style metering or billing layer, and no API-key config block to purchase or enable.

Agent authorization

An API key is a long-lived secret good at every door. An agent wants the opposite: a token that expires in minutes, names the API it may be presented to, and stops working the moment you revoke it. Two roads lead there. Road one rides the ordinary OAuth code flow and is in every build. Road two lets the customer’s own identity provider make the authorization decision, and is compiled in by --features external-jwt.

Road one — bind the token to the API it is for

Send RFC 8707 resource at /api/auth/oauth2/authorize or /api/auth/mcp/authorize, once per target the token is meant for:

GET /api/auth/oauth2/authorize?response_type=code&client_id=…&redirect_uri=…
    &code_challenge=…&code_challenge_method=S256
    &resource=https%3A%2F%2Fmcp.example.com%2F
    &resource=https%3A%2F%2Fcrm.example.com%2F

At most eight canonical absolute URIs per request; a ninth, or a duplicate, refuses. resource is the only parameter that may repeat — a repeated state, scope, client_id, or redirect_uri draws a 400 naming the field. An uncanonical value refuses at authorize with invalid_target, delivered to the registered redirect_uri, and mints no code. The whole set survives the login detour and the consent round-trip, and a refresh copies it from the row it rotates rather than from the request, so a refreshed token can neither widen its targets nor invent one.

Cut the token’s life down to one agent turn. A single key moves the OAuth and MCP doors together:

[oauth]
access_ttl_seconds = 900     # default 3600, accepted range 300-3600

Outside that range the instance refuses at boot and names the range. Five to thirty minutes (3001800) is the agent profile: a leaked bearer stays useful for minutes rather than an hour, and the cost is one refresh round-trip per interval for clients holding offline_access.

The binding is enforced, not merely recorded. A token bound to one resource and presented at another does not quietly pass:

Certified introspection, naming a resourcePOST /api/auth/oauth2/introspect → 400 invalid_target
Universal introspection, expecting a resourcePOST /api/auth/tokens/introspect → 403 TOKEN_RESOURCE_MISMATCH
MCP session doorsGET /api/auth/mcp/get-session · /api/auth/mcp/userinfo → 200 null

An unbound token — one whose flow never sent a resource — stays unrestricted, so a client that does not use resource indicators sees no change at all. But a caller that names an expected resource is told TOKEN_RESOURCE_UNBOUND rather than handed the token as unconfined. Revocation reaches the whole access family: POST /api/auth/oauth2/revoke kills an MCP access token as well as an OAuth one, and introspection and userinfo walk that same family, so one truth about a token holds at every door.

No resource_indicators_supported member appears in the discovery documents, deliberately: RFC 8707 defines no such metadata parameter, so publishing one would be an invention. An MCP client learns the identifier to send from the resource member of /api/auth/.well-known/oauth-protected-resource — this server’s origin — while the sibling authorization_servers entry carries the base path.

Road two — not compiled into this build

There is a second road, in which the customer’s own identity provider makes the authorization decision and signs an assertion the agent redeems here for a Signet token. It is compiled in by --features external-jwt, and this binary was not built with it. Nothing in this build’s discovery documents advertises it, and its token endpoint refuses the enterprise grant, so an agent client fails safe rather than half-working. Rebuild with that feature — it is pure Rust, links no system library, and changes nothing about your build host — if you need it.

Flow hooks and claim mappers

Production config accepts ordered [[hooks.pre_sign_up]], [[hooks.pre_sign_in]], [[hooks.post_sign_in]], and [[claims.mappers]] blocks. They are boot-validated and compiled to an immutable plan; this experimental phase stores that plan without executing it on requests.

The complete path-fact table, first-match and default rules, wire-safe refusal shapes, mapper collision and byte-cap rules, hostile-consumer warning, and worked TOML are served at /docs/flow-hooks.

Event webhooks

Configure [events] for the six-event catalog: user.created, account.locked, session.created, session.annotated, session.revoked, and the deliberately no-PII user.deleted. Verify HMAC-SHA256 over the verbatim x-signet-timestamp string, one ASCII dot, and the raw body before parsing. Treat only that Unix-seconds header as the freshness clock.

Delivery is at-least-once and unordered. Deduplicate durable work on event.id, accept old envelope timestamps when the delivery header is fresh, return 2xx within the sender's 10-second timeout, and use distinct [events].secret and [delivery.webhook].secret values. Read the complete language-neutral contract, rotation order, conformance command, and pinned vectors at /docs/event-webhooks.

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.

Accepted residuals: a slow attempt stream below the configured window is not accumulated forever; a persistent attacker can keep a victim cycling through capped locks, so subscribe to 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.

Do not copy @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.

Templates shape an EdDSA token; they do not enroll Signet with a relying party. Verify the published /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.

Reverification never rewrites public session 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.

There is no privileged testing-token endpoint. The helper cannot create a user, skip MFA, ignore a ban or lockout, disable a rate limit, or extend a session. An MFA account returns 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.

Truthful claim: this deployment uses edge WAF/rate controls in front of Signet. Do not claim that Signet includes bot detection or CAPTCHA. Full deployment and test contract: docs/bot-protection.md in the distribution.

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 (selects PostgreSQL or encrypted SQLite by URL scheme), 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).

KeyTypeRequiredDescription
secretOption<String>noThe 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_inOption<bool>noSign 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.

KeyTypeRequiredDescription
listenOption<String>noSocket address to bind. Default: 127.0.0.1:3000. Env: SIGNET_LISTEN.
base_urlOption<String>noThis instance's public origin, e.g. https://auth.example.com. Set here or export SIGNET_BASE_URL.
base_pathOption<String>noPath 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_originsVec<String>noExtra origins allowed for CORS/callback validation beyond base_url.
trust_forwarded_headersboolnoBelieve 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.
scim_default_organization_idOption<String>noOrganization ID bound to SCIM provider connections whose create body omits organizationId. Default: unset, which refuses org-less creates. Set this only to a deliberately selected, existing organization whose owners and admins may manage those connections. Signet never infers it from stored organizations or from a request.

[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 and HA, set adapter = "postgres" with dsn. adapter = "sqlite" (supported) uses one encrypted local SQLCipher file named by path; key must be an env: or separate file: reference. It refuses :memory:, URI/network filesystems, a second live process, and external writers. SIGNET_DATABASE_URL selects PostgreSQL or SQLite by URL scheme and overrides adapter, dsn, and path.

KeyTypeRequiredDescription
adapterOption<String>noStorage adapter: "postgres", "sqlite", or "memory" for dev. SQLite is one encrypted local file per instance; it requires an out-of-band SQLCipher key and refuses network filesystems, a second live process, and external writers.
dsnOption<String>noPostgreSQL connection string. Prefer env:SIGNET_DATABASE_URL; a postgres:// or postgresql:// override selects this adapter.
pathOption<String>noLocal SQLite file path. Use a writable local filesystem, never :memory:, a URI, or a network mount.
keyOption<String>noSQLCipher raw key as exactly 64 hexadecimal characters from an env: or file: reference. Inline key material is refused; a referenced file must not be beside the database file.
migrateOption<bool>noRun embedded migrations on boot. Default: true.
max_connectionsOption<u32>noConnection-pool ceiling. Default: adapter default. SQLite requires at least 3 so pooled resolution retains one general-operation connection.

[delivery]

How user-bound messages (verification codes, reset links) leave the instance. Omit for no delivery.

KeyTypeRequiredDescription
modeOption<String>noDelivery channel: "webhook", or experimental "smtp", "ses", or "resend". Default: none.
dead_letterboolnoRetain 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").

KeyTypeRequiredDescription
urlOption<String>noDestination URL for signed-JSON delivery POSTs.
secretOption<String>noHMAC-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]

EXPERIMENTAL SMTP delivery (when mode = "smtp"). The transport passes the delivery TCK, but the current live relay accepted a message without returning a provider message id, so the D294 support receipt is still open.

KeyTypeRequiredDescription
hostOption<String>noSMTP server hostname.
portOption<u16>noSMTP server port (e.g. 587).
usernameOption<String>noSMTP auth username, if the server requires it.
passwordOption<String>noSMTP auth password; prefer an env:/file: ref.
fromOption<String>noEnvelope From address.
timeout_secondsOption<u64>noPer-send SMTP deadline in seconds. Default: 10; range: 1..=300.

[delivery.ses]

EXPERIMENTAL Amazon SES v2 API delivery (when mode = "ses"). Static IAM credentials must be supplied through env:/file: references; ambient and STS credentials are out of scope.

KeyTypeRequiredDescription
regionOption<String>noAWS region for the SES v2 endpoint and SigV4 credential scope.
access_key_idOption<String>noStatic IAM access-key id via an env: or file: reference. Ambient, instance-profile, ECS/EKS and STS/session credentials are out of scope.
secret_access_keyOption<String>noStatic IAM secret access key via an env: or file: reference only.
fromOption<String>noVerified SES sender mailbox.
timeout_secondsOption<u64>noPer-send HTTP deadline in seconds. Default: 10; range: 1..=300.

[delivery.resend]

EXPERIMENTAL Resend Email API delivery (when mode = "resend"). The API key must be supplied through an env:/file: reference; Signet emits a stable provider idempotency key on every send.

KeyTypeRequiredDescription
api_keyOption<String>noResend API key via an env: or file: reference only.
fromOption<String>noSender mailbox on a Resend-verified domain.
timeout_secondsOption<u64>noPer-send HTTP deadline in seconds. Default: 10; range: 1..=300.

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

KeyTypeRequiredDescription
email_otp_subjectOption<String>noEmail-OTP subject. Variables: {{email}}, {{recipient}}, {{otp_type}}.
email_otp_bodyOption<String>noEmail-OTP body. Variables: {{email}}, {{recipient}}, {{otp}}, {{otp_type}}; must contain {{otp}}.
magic_link_subjectOption<String>noMagic-link subject. Variables: {{email}}, {{recipient}}.
magic_link_bodyOption<String>noMagic-link body. Variables: {{email}}, {{recipient}}, {{url}}, {{token}}; must contain {{url}}.
verification_email_subjectOption<String>noVerification-email subject. Variables: {{email}}, {{recipient}}.
verification_email_bodyOption<String>noVerification-email body. Variables: {{email}}, {{recipient}}, {{url}}, {{token}}; must contain {{url}}.
password_reset_subjectOption<String>noPassword-reset subject. Variables: {{email}}, {{recipient}}.
password_reset_bodyOption<String>noPassword-reset body. Variables: {{email}}, {{recipient}}, {{url}}, {{token}}; must contain {{url}}.
invitation_subjectOption<String>noInvitation subject. Variables: {{email}}, {{recipient}}, {{organization_name}}, {{inviter_email}}.
invitation_bodyOption<String>noInvitation 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.

KeyTypeRequiredDescription
urlOption<String>noApp endpoint that receives the six signed event types documented at /docs/event-webhooks, including session.annotated.
secretOption<String>noHMAC-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_letterboolnoRetain events that fail delivery in the eventDeadLetter store for later replay from the admin surface. Default: false.
receiptsOption<bool>noPersist successful event-delivery rollup receipts. Failed attempts are always persisted. Default: true.
receipt_retention_daysOption<u32>noRetain delivered and exhausted event-delivery rollups for this many days. Pending retry rows are never pruned. Default: 30.
receipt_max_rowsOption<u64>noRetain at most this many delivered and exhausted event-delivery rollups. Pending retry rows are never pruned. Default: 100000.

[[hooks.pre_sign_up]]

An ordered sign-up admission rule. First match wins and no match allows the request; allow never bypasses ordinary security fences.

KeyTypeRequiredDescription
whenHookWhenyesClosed inline predicate: email, email_domain, auth_method, provider_id, client_id.
actionStringyesrefuse or allow; allow means only that no hook refuses.
messageOption<String>noOptional operator-only refusal prose; it is logged and never sent on the wire.

[[hooks.pre_sign_in]]

An ordered sign-in admission rule. First match wins and no match allows the request.

KeyTypeRequiredDescription
whenHookWhenyesClosed inline predicate: email, email_domain, auth_method, provider_id, client_id.
actionStringyesrefuse or allow; allow means only that no hook refuses.
messageOption<String>noOptional operator-only refusal prose; it is logged and never sent on the wire.

[[hooks.post_sign_in]]

An ordered session.annotated event rule. It never writes a session flag.

KeyTypeRequiredDescription
whenHookWhenyesClosed inline predicate: email, email_domain, auth_method, provider_id, client_id.
actionStringyesMust be annotate; post-sign-in hooks cannot allow or refuse.
annotationStringyesNon-empty, at-most-64-character label emitted as session.annotated.

[[claims.mappers]]

A boot-validated claim mapper shared by id_token and userinfo. Every mapper is explicitly opted into by client and may not target a builder-owned claim.

KeyTypeRequiredDescription
nameStringyesOutput claim name; every claim the built-in builders can emit is reserved.
sourceStringyesuser_field, org_role, membership_orgs, static, or template.
fieldOption<String>noRequired only for source = "user_field"; names one declared metadata field.
valueOption<serde_json::Value>noRequired only for source = "static"; serialized output is capped at 1024 bytes.
templateOption<String>noRequired only for source = "template"; UTF-8 template text is capped at 1024 bytes.
whenOption<HookWhen>noOptional closed predicate using the same append-only vocabulary as hooks.
clientsVec<MapperClientOptIn>noExplicit per-client opt-ins; org sources require organization_id on every entry.

[user]

Container for typed custom-user-field declarations validated at request boundaries and stored through publicMetadata.

KeyTypeRequiredDescription
fieldsBTreeMap<String, UserFieldDeclaration>noDynamic field declarations: { type = "string|bool|int", max_length = ..., required = ... }.

[user.fields.department]

One typed custom-user-field declaration. Inline entries under [user.fields], such as department = { type = "string", max_length = 64, required = false }, are equivalent.

KeyTypeRequiredDescription
typeStringyesstring, bool, or int; the vocabulary is closed.
max_lengthOption<usize>noRequired positive UTF-8 byte cap for strings; forbidden on bool and int.
requiredboolnoWhether sign-up requires this field; defaults false.

[session]

Session lifetime knobs (seconds).

KeyTypeRequiredDescription
expires_inOption<i64>noSession lifetime in seconds. Default: engine default (7 days).
update_ageOption<i64>noSeconds before a session's expiry is refreshed on use. Default: engine default.
fresh_ageOption<i64>noSeconds 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>.

KeyTypeRequiredDescription
default_expires_inOption<i64>noLifetime in seconds for the ordinary, untemplated session JWT. Default: 900.

[jwks]

EdDSA/RS256 ID-token trust-root default, cache, and rotation grace. JWKS rotation is distinct from SIGNET_SECRET cookie/action-HMAC rotation.

KeyTypeRequiredDescription
default_algorithmOption<String>noAssert the durable install-generation default: EdDSA or RS256. Omit this on ordinary installs; migration 0088 records EdDSA for an estate that already had a signing key and RS256 for an empty estate. A value that disagrees with that durable policy refuses startup rather than silently changing what existing clients receive.
cache_max_age_secondsOption<u32>noPublic cache lifetime in seconds. Default: 60; range 1..=3600. The first U15-capable startup persists this value as the immutable estate-wide JWKS policy; every later node must configure the same value or startup and all signing/publication requests fail closed. Changing it requires a future coordinated policy-change protocol, not a config-only edit.
signing_grace_secondsOption<i64>noRetiring-key publication grace in seconds. Default: 2592000; must be at least 86400 + cache max-age + 60 seconds.

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

KeyTypeRequiredDescription
nameStringyesURL-safe selector (ASCII letters/digits plus ., _, -; max 64 bytes).
audienceOption<String>noOptional aud value. Omit to use server.base_url.
expires_inOption<i64>noToken lifetime in seconds (60..=86400). Default: [jwt].default_expires_in.
claimsserde_json::Map<String, serde_json::Value>noJSON-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.

KeyTypeRequiredDescription
minOption<usize>noMinimum password length. Default: 8.
maxOption<usize>noMaximum password length. Default: 128.
min_strengthOption<u8>noMinimum zxcvbn strength score (0 disables; accepted range 0-4). Default: 0.
scrypt_concurrencyOption<usize>noscrypt parallelism factor (must be ≥ 1). Default: 4.

[rate_limit]

Built-in rate limiter. On by default.

KeyTypeRequiredDescription
enabledOption<bool>noEnable the built-in rate limiter. Default: true.
storageOption<String>noCounter storage: "memory" (default) or "database" for a shared PostgreSQL quota across processes.
windowOption<i64>noDefault window in seconds. Default: 10.
maxOption<i64>noDefault max requests per window. Default: 100.

[[rate_limit.rules]]

Per-path override rules (repeat the block per rule).

KeyTypeRequiredDescription
pathStringyesThe path (relative to base_path) this rule applies to, e.g. /sign-in/email.
windowi64yesWindow in seconds for this rule.
maxi64yesMax requests per window for this rule.

[resolution]

Bounded positive canonical API-key/service-token resolution cache and isolated introspection/validation concurrency lanes (D211). PostgreSQL derives lanes from the adapter's reported primary connection limit and requires their sum to leave one general-operation slot. Custom pooled DbAdapter wrappers must forward that capability truthfully; replacing an adapter after AppState construction is unsupported because derived cache/gates are not rebuilt. Direct/library callers constructing AppState with a PostgreSQL adapter must set ResolutionConfig::postgres_conformance(pool_limit), or an equally pool-safe explicit split, before construction; AppState::new refuses an overcommitted caller-supplied split instead of silently clamping it.

KeyTypeRequiredDescription
cache_enabledOption<bool>noEnable the process-local positive API-key/service-token cache. Default: true.
cache_ttl_msOption<u64>noPositive-entry freshness in milliseconds. Must be 1..=500; default 500.
cache_max_entriesOption<usize>noMaximum cached canonical entries. Must be 1..=4096; default 4096.
introspection_max_inflightOption<usize>noMaximum concurrent introspection resolutions. Default: 48 on Memory; PostgreSQL derives a pool-aware default and refuses an overcommitted explicit value.
validation_reserved_inflightOption<usize>noReserved concurrent validation/admin resolutions. Default: 16 on Memory; PostgreSQL derives a pool-aware default and refuses an overcommitted explicit value.

[lockout]

Persistent email-keyed credential lockout with capped exponential backoff. A successful password reset or the admin unlock action clears the row.

KeyTypeRequiredDescription
enabledOption<bool>noEnable persistent email-keyed account lockout. Default: true.
max_failuresOption<i64>noFailed credential attempts allowed in one window. Default: 5.
windowOption<i64>noFailure-counting window in seconds. Default: 600 (10 minutes).
lock_durationOption<i64>noFirst lock duration in seconds. Default: 900 (15 minutes).
backoff_multiplierOption<i64>noMultiplier applied for each consecutive lock. Default: 2.
max_lock_durationOption<i64>noBackoff ceiling in seconds. Default: 86400 (24 hours).
lock_level_decayOption<i64>noClean 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).

KeyTypeRequiredDescription
allowVec<String>noAdmission rules: exact emails, apex domains, or *.example.com (subdomains only).
blockVec<String>noDenial 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.

KeyTypeRequiredDescription
enabledOption<bool>noBlock disposable domains on identity creation. Default: true.
list_pathOption<String>noReplace the bundled snapshot with this local file (one domain per line).
extra_denyVec<String>noExtra parent domains to deny in addition to the selected snapshot.
allowVec<String>noExact/domain/wildcard carve-outs applied within disposable matching.

[plugins]

Optional engine plugins.

KeyTypeRequiredDescription
oauth_proxyOption<bool>noEnable the OAuth proxy plugin. Default: engine default.
haveibeenpwnedOption<bool>noEnable the Have I Been Pwned breached-password check. Default: engine default.
hibp_range_endpointOption<String>noOverride the HIBP range API endpoint (for a self-hosted mirror).

[compat]

Doors served for a FOREIGN wire — endpoints another product's clients already speak, so a fleet can be migrated onto Signet one service at a time. Not part of the certified better-auth surface; each door defaults off, and a disabled door is not registered at all rather than answering as a stub.

KeyTypeRequiredDescription
kapable_validateOption<bool>noServe POST/GET /v1/auth/validate, the Kapable fleet's trust-root door (R175/D148; contract in docs/product/14-KAPABLE-VALIDATE-CONTRACT.md). Default: false — the route is not registered at all, so a disabled instance 404s it by absence. true requires bridge_resource in the same section; incomplete pairs stop boot.
bridge_resourceOption<String>noExact D150 resource whose action array the enabled foreign-wire adapter projects as flat scopes. This is deliberately configured rather than a product constant: with the compatibility door off, Signet reserves no customer-specific word in the application permission grammar.

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

KeyTypeRequiredDescription
login_pageOption<String>noWhere /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_pageOption<String>noWhere /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.
access_ttl_secondsOption<i64>noHow long an OAuth2 or MCP access token lives, in seconds. Default: 3600 (one hour), accepted range 300-3600. Agent clients that hold a token for the length of a task should use the short end — 300-1800, five to thirty minutes — so a leaked bearer expires in minutes; the cost is a refresh round-trip per interval for clients holding offline_access.

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

KeyTypeRequiredDescription
sign_up_pageOption<String>noWhere 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_pageOption<String>noThe "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_pageOption<String>noWhere 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).

KeyTypeRequiredDescription
idStringyesProvider id, e.g. google or github (built-in defaults), or a custom id.
client_idStringyesOAuth client id.
client_secretStringyesOAuth client secret; prefer an env:/file: ref.
authorization_endpointOption<String>noAuthorization endpoint. Required for custom providers; defaulted for google/github.
token_endpointOption<String>noToken endpoint. Required for custom providers; defaulted for google/github.
user_endpointOption<String>noUserinfo endpoint. Required for custom providers; defaulted for google/github.
scopesOption<Vec<String>>noOAuth scopes to request. Defaulted for google/github.
pkceOption<bool>noUse PKCE. Defaulted for google/github.

[admin]

The instance-scoped admin surface (/admin/v1 + the /admin dashboard). Off unless enabled = true; key is a bootstrap carrier and is optional after managed rotation.

KeyTypeRequiredDescription
enabledOption<bool>noTurn the admin surface on. Default: false (no /admin/v1, no /admin dashboard — an unconfigured instance answers those paths with 404).
keyOption<String>noBootstrap carrier (≥ 32 chars). Supply out-of-band via SIGNET_ADMIN_KEY; required only while establishing/proving a bootstrap-only credential estate. Remove it after managed rotation.

[admin_plugin]

The better-auth-compatible, end-user-session-authenticated admin plugin. This is separate from the platform-credential [admin] operator surface.

KeyTypeRequiredDescription
default_roleOption<String>noRole assigned by admin create-user when no role is requested. Default: user.
admin_rolesOption<Vec<String>>noRoles with the built-in admin permissions. Default: ["admin"].
admin_user_idsOption<Vec<String>>noUser ids that receive every admin permission regardless of role. Default: [].
rolesOption<Vec<String>>noOptional role allow-list for create-user and set-role. Omit to accept any string.
impersonation_session_durationOption<i64>noMaximum impersonation-session lifetime in seconds. Default: 3600; range: 60..=86400.
allow_impersonating_adminsOption<bool>noPermit 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.

KeyTypeRequiredDescription
enabledOption<bool>noRegister the /siwe/* routes. Default: true. Set false to remove them.
rpc_urlsHashMap<String, String>noEthereum 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. RFC 7591 registration is OFF by default. When enabled in production it requires a live principal session and binds the confidential client to that owner; only the isolated conformance profile preserves anonymous registration for the certified fixture.

KeyTypeRequiredDescription
registration_enabledOption<bool>noServe RFC 7591 dynamic client registration on /mcp/register. Default: false. Production additionally requires a live principal session and binds the client to that owner; the isolated conformance profile alone preserves the anonymous upstream fixture.

[ssh_ca]

A dedicated Ed25519 OpenSSH user certificate authority for one explicitly configured, pre-existing Unix account. Disabled by default. POST {base_path}/ssh/certificates issues to a caller who is a member of organization_slug AND holds owner in it AND has authenticated recently — all three, or 403. Every issuance is recorded; a certificate is never returned without its ledger row, because the certificate names an opaque id rather than a person. Signet never generates the trust root, creates Unix users, signs host certificates, or provides instant revocation; certificate lifetime is the revocation bound. Run this signer on an instance whose only identities are operators: on one that also serves customer tenants, a signup becomes a candidate for fleet root.

KeyTypeRequiredDescription
enabledOption<bool>noEnable OpenSSH user-certificate signing. Default: false. Enabling requires private_key; Signet never generates a CA key at boot.
private_keyOption<String>noExisting 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. Prefer file: for a fleet CA (D314 ruling 2). An env: value is readable from a crash handler's dump, a systemd-coredump capture, and /proc/<pid>/environ for anything running as the same uid — three copies nobody audits. env: stays supported for deployments whose secret delivery is environment-only; it is the wrong choice for a key that opens every host in an estate.
principalOption<String>noThe 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.
organization_slugOption<String>noSlug of the infrastructure organization whose operators may mint (D314 ruling 1). Required when enabled. There is deliberately no default: an instance that enables the signer without naming an organization refuses to boot rather than falling back to "any authenticated caller", which on a customer-serving instance would let a trial signup mint fleet root.
max_authentication_age_secondsOption<u64>noHow recently the caller must have actually authenticated to mint, in seconds. Default: 300; maximum accepted value: 3600. ⚠ This is deliberately NOT [session] fresh_age, which defaults to 86400. Reusing it would make D314 ruling 3 decorative: a stolen cookie would mint fleet root for a day, where today an attacker needs a key FILE. It is also a different policy — fresh_age governs how often the account portal re-asks, and an operator who loosens that for UX must not silently loosen who can open every host. A caller past this window re-authenticates at POST {base_path}/reverify/password and retries.
default_ttl_secondsOption<u64>noTTL used when a request omits one. Default: 600 seconds.
maximum_ttl_secondsOption<u64>noHard request TTL cap. Default: 3600; maximum accepted value: 86400. ⚠ This caps the REQUEST, not the certificate's validity window. Because valid_after is backdated by clock_skew_seconds, a certificate issued at this cap is valid for maximum_ttl_seconds + clock_skew_seconds of wall time. The issuance response reports both numbers separately for exactly this reason.
clock_skew_secondsOption<u64>noBackdate valid_after for bounded host/workstation clock disagreement. Default: 60 seconds; maximum accepted value: 300. It widens every certificate's validity window by this much — see maximum_ttl_seconds.
permit_ptyOption<bool>noAdd OpenSSH's permit-pty extension. Default: false (explicit opt-in).
permit_agent_forwardingOption<bool>noAdd permit-agent-forwarding. Default: false.
permit_port_forwardingOption<bool>noAdd permit-port-forwarding. Default: false.
permit_user_rcOption<bool>noAdd permit-user-rc. Default: false.
permit_x11_forwardingOption<bool>noAdd 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 seven classes Signet mints — session, api-key, service-token, delegated-token, app-session, 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. Omit prefix to inherit the built-in class's minted prefix, or repeat that exact value as an assertion; a different value is refused at boot.

KeyTypeRequiredDescription
nameStringyesThe registry-unique kind name. Reported on the wire as kind; when used as token_type_hint, it orders this kind's verifier first without fencing the required fallback search. Operator-owned: rename a class and stock RFC 7662 clients keep working, because access_token and refresh_token resolve through the class, not the name.
storageStringyesWhat 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. Every built-in kind is currently hashed; the required declaration keeps that an assertion, not an assumed default.
lifetimeStringyesWhen 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".
prefixOption<String>noThe dispatch prefix credentials of this kind begin with. Omit it to inherit the built-in class's minted prefix, or repeat that exact value as an assertion. A different value is refused at boot.
formatOption<String>noWire format: opaque or jwt. Default: the class's real format.
revocableOption<bool>noWhether 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.
introspectableOption<bool>noWhether 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 gives an undisclosable credential RFC 7662's uniform {"active":false} body, while 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.
audienceOption<Vec<String>>noThe resource servers credentials of this kind are intended for. A non-empty list is emitted as RFC aud; an omitted or empty list omits aud rather than emitting an unusable empty audience. This remains informational kind-level metadata: U11's enforced per-credential RFC 8707 bindings live independently on service/delegated rows. Operator-owned.
entropy_bytesOption<u64>noDesign 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_requiresOption<String>noDesign 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" }.

KeyTypeRequiredDescription
viaStringyesHow 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.
classOption<String>noWhich built-in credential class verifies this kind, when via = "builtin": session, api-key, service-token, delegated-token, app-session, 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.

KeyTypeRequiredDescription
tokenOption<String>noThe 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.
fingerprintOption<String>noThis DEPLOYMENT's identity for the licence's machine binding (F1/D247 §6): matched against the fp the token was activated with. Declare ONE stable value for the whole estate (e.g. "acme-prod-signet" in your config template) — never an ephemeral source like a pod hostname or boot ID, which re-trips the binding (and Warrant's activation cap) on every reschedule. env:/file: refs and SIGNET_LICENSE_FINGERPRINT work like the token's. Omit to run un-bound (a bound token then warns rather than enforces).
enforceOption<bool>noRefuse 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.

All other paths (default)100 / 10s
/sign-in, /sign-up, /change-password, /change-email3 / 10s
/request-password-reset, /forget-password, /send-verification-email, /email-otp/send-verification-otp, /email-otp/request-password-reset3 / 60s

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.

Every node sharing database counters must run the same rate-limit rules and a synchronized system clock. The client-IP proxy boundary remains unchanged; see 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.

idProvider id — google, github, or a custom id
client_idOAuth client id
client_secretOAuth client secret — use an env:VAR ref, never inline
authorization_endpointRequired for custom providers; defaulted for google/github
token_endpointRequired for custom providers; defaulted for google/github
user_endpointOptional userinfo endpoint; defaulted for google/github
scopesOAuth scopes to request; defaulted for google/github
pkceUse PKCE; defaulted for google/github

A 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
Caveat: migrations are forward-only — no down-migrations ship. Roll back by redeploying the previous binary before a new schema migration has applied; once it has run, roll back by restoring a pre-upgrade backup (below). The in-memory adapter keeps nothing across a restart; it is for development only.

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
Caveat: the 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.

You can leave with your data at any time, with no cooperation from Signet or its authors required. Sealed instances phone home to nobody; a backup or export is a local operation against your own database.

Certification & support

This instance's compatibility receipt: /certification (JSON). Machine on-ramp for AI agents: /llms.txt.

Self-serve documentation plus best-effort support. No SLA is offered or implied.