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
Start SSOPOST /api/auth/sign-in/sso
OIDC callback (redirect URI)GET /api/auth/sso/callback/{providerId}

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 — the enterprise makes the decision

This build carries the ID-JAG grant (Identity Assertion JWT Authorization Grant — the MCP Enterprise-Managed Agents profile). The customer’s own identity provider decides that this agent may reach this resource on this user’s behalf, and signs an assertion saying so. The agent presents the assertion at the token endpoint and receives an ordinary Signet access token. Nobody provisions a per-tenant credential out of band, because the assertion carries the decision — and its resource claim lands as the same binding road one writes, through the same canonicalizer and the same token writer.

Grant typeurn:ietf:params:oauth:grant-type:jwt-bearer
Grant profileurn:ietf:params:oauth:grant-profile:id-jag
Redeem atPOST /api/auth/oauth2/token
Client authenticationprivate_key_jwt · client_secret_basic · client_secret_post

Both discovery documents — /api/auth/.well-known/oauth-authorization-server and /api/auth/.well-known/openid-configuration — carry the grant in grant_types_supported, the profile in authorization_grant_profiles_supported, private_key_jwt in token_endpoint_auth_methods_supported, and client_id_metadata_document_supported. A build compiled without external-jwt publishes none of them and answers unsupported_grant_type at the token endpoint. The condition is the build, never a runtime switch, so no configuration can make this instance advertise a capability it does not carry.

Trust is configured, never discovered

The grant takes its key source from an ssoProvider row under the client’s organization. It never reads jku, x5u, or x5c from the assertion header, and never fetches what they name. The row carries providerId, issuer, organizationId, and an oidcConfig — a JSON string — naming the key set:

{"jwksEndpoint": "https://acme.okta.com/oauth2/v1/keys"}

Leave jwksEndpoint out and it hydrates from the issuer’s /.well-known/openid-configuration at redemption time, behind the same SSRF fence registration applies to a typed URL. issuer matches by RFC 3986 Simple String Comparison — equality, not normalization. The provider’s key may be RSA (RS256/RS384/RS512), EC (P-256ES256, P-384ES384), or OKP (EdDSA); the JWK’s key type decides the accepted algorithm, never the assertion’s own header, so an HMAC alg pointed at a public key set dies before verification.

POST /api/auth/sso/register is an enterprise-sso route. A build carrying only external-jwt does not mount it and so has no HTTP surface that writes the provider row — build enterprise-sso if you need one, or seed the row directly.

Registering the agent’s client

A confidential client, inside the organization whose identity provider issues the assertion, registered for the grant. /api/auth/oauth2/create-client binds the client to the organization selected on the session cookie:

curl -X POST "https://auth.example.com/api/auth/oauth2/create-client" \
  -H 'content-type: application/json' \
  -H "cookie: $SESSION_COOKIE" \
  --data '{"redirect_uris": ["https://agent.acme.example/callback"],
           "grant_types": ["authorization_code",
                           "urn:ietf:params:oauth:grant-type:jwt-bearer"],
           "token_endpoint_auth_method": "private_key_jwt",
           "jwks": {"keys": [{"kty":"OKP","crv":"Ed25519","kid":"client-key-1","use":"sig","x":"…"}]}}'

The grant must sit on the row: the allow-list defaults closed, so a client registered before you added the URN is refused with unauthorized_client and told to register it. /api/auth/oauth2/update-client takes the same field. A private_key_jwt client gets no client_secret — its credential is a signature, and a shared secret beside the key would be a second, weaker way in that nothing rotates. Send exactly one key source, jwks by value or jwksUri by reference; neither, both, or a non-object jwks draws a named 400, and the one-source rule is judged over the merged row on update. An inline jwks costs no outbound request at token time.

What the assertion must carry

header   {"alg":"EdDSA","typ":"oauth-id-jag+jwt","kid":"idp-key"}
claims   {"iss":"https://acme.okta.com",          ← the registered provider's issuer, exactly
          "aud":"https://auth.example.com/api/auth",  ← this server's ISSUER IDENTIFIER, and only it
          "sub":"idp-subject-1",                  ← a user that already exists here
          "client_id":"…",                        ← the client authenticating this request
          "jti":"…", "iat":…, "exp":…,
          "scope":"openid profile",
          "resource":"https://mcp.example.com/"}  ← REQUIRED here, though ID-JAG marks it optional

typ must read oauth-id-jag+jwt: an ordinary identity-provider ID token is not a grant, and it fails on typ and on aud independently. aud must be exactly this server’s issuer identifier — one string, or an array of one; an array of two refuses even when one element is correct. exp carries 60 seconds of skew and must fall within an hour; nbf binds when present; jti and iat are required. client_id must name the authenticated client, so one client’s decision is not redeemable by another that happens to hold the assertion. scope narrows to the intersection with the client’s registered scopes, always minus offline_access — and an absent scope claim grants nothing, never the client’s defaults. Redeem it:

curl -X POST "https://auth.example.com/api/auth/oauth2/token" \
  -d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
  -d assertion="$ID_JAG" \
  -d client_id="$CLIENT_ID" \
  -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
  -d client_assertion="$CLIENT_ASSERTION"

The client assertion (RFC 7523 §2.2) carries iss and sub both equal to the client_id, a jti, an exp inside an hour, and an aud naming either this server’s issuer identifier or its token endpoint URL — the RFC admits both spellings and both authenticate here. A client holding a secret uses HTTP Basic instead; sending a secret and an assertion in one request refuses.

What comes back

{"access_token":"sig_at_…","token_type":"Bearer","expires_in":300,
 "expires_at":…,"scope":"openid profile","resource":["https://mcp.example.com/"]}

No refresh token, ever. The response body carries no such key — not a flag that happens to read false. Re-presenting the same assertion is the refresh mechanism, and it mints a fresh token every time. The token never outlives the decision that authorized it: its life is min(access_ttl_seconds, exp − now), measured against the stored assertion rather than the response. What comes back is an ordinary access credential — it introspects live at the resource the enterprise named and answers invalid_target anywhere else.

An assertion-issued token does not authenticate at /api/auth/mcp/get-session or /api/auth/mcp/userinfo. Those doors read the MCP access family, the lookup misses, and they answer their documented null. Use it against the resource server the resource claim named.

What road two refuses on purpose

A client identified by a URL (CIMD) that no organization has adopted401 invalid_client — ask an owner or admin to adopt it at /api/auth/oauth2/adopt-client
An adopted client whose published keys have changed401 invalid_client — an administrator re-confirms the adoption at the same route; the server never accepts a key nobody approved
A public client, registered or not401 invalid_client — register a confidential client (ID-JAG §9.1)
A subject with no local user, or no membership in that organization400 invalid_grant — provision the user first; this grant does no just-in-time provisioning
An assertion carrying no resource claim400 invalid_grant — configure the identity provider to send the RFC 8707 target the assertion authorizes
A plaintext key-set endpoint outside trusted_origins400 invalid_grant before the request ever leaves — serve the key set over TLS, or list the internal origin in trusted_origins
aud naming more than one audience400 invalid_grant — audience the assertion at this server’s issuer identifier alone

Every assertion defect answers one byte-identical invalid_grant description, deliberately: a caller who can tell the checks apart holds a validator oracle, and ID-JAG §9.4 forbids disclosing which issuers a customer has registered. The server log names the check that refused, and every one of those lines states the limit and the corrective action. Four more conditions refuse for reasons worth knowing — an issuer registered twice inside one organization, an assertion whose iss is this server, an unknown form member, and a client-metadata URL that is http, loopback, private-network, cloud-metadata, path-less, or non-canonical. That last one refuses before any socket opens and reads exactly like “no such client”, because telling the two apart would map what this server will fetch.

A URL client_id is a verified identity only. Since R292 it counts at /api/auth/oauth2/authorize and /api/auth/mcp/authorize as well as at the token endpoint, so such a client can run the whole authorization-code flow with no registration. On both doors: redirect_uri must byte-match one the fetched document declares, PKCE S256 is required, and the user must approve a consent screen that shows the document’s URL beside the name it claims — the name is chosen by whoever published the document, the URL is the part nobody else can claim. No code is issued to a document-identified client until that approval is on record. A registered client row always wins over a document published at the same URL, at every one of those doors. What the identity does not confer by itself is an organization, which is what the next section is for.

Adopting a self-published client

A client that identified itself by publishing a document belongs to nobody, so it cannot speak for a tenant until an organization says it may. An owner or admin adopts it — an allow-list entry keyed on the exact URL, not a registration, and the client stays self-described:

curl -X POST "https://auth.example.com/api/auth/oauth2/adopt-client" \
  -H "content-type: application/json" -b "$SESSION_COOKIE" \
  -d '{"organizationId":"$ORG","clientId":"https://agent.example.com/client.json"}'

Signet fetches the document, records the RFC 7638 thumbprints of the keys it publishes today, and from then on the client resolves that organization’s tenant. If those keys ever differ, every redemption refuses until an owner or admin calls the same route again — the server will not accept a key nobody approved, and the cost of that is real: a client that rotates its keys has to be re-confirmed by each organization that adopted it. GET /api/auth/oauth2/client-adoptions?organizationId=… lists what an organization holds; POST /api/auth/oauth2/revoke-client-adoption withdraws one, keeping the row so it still records who authorized the agent and when.

More than one organization may adopt the same public agent, which is the ordinary case rather than an edge one. When several have, the tenant is the one whose identity provider issued the assertion and in which the assertion’s subject is already a member; if that is not exactly one organization, the redemption refuses rather than choosing.

Event webhooks

Configure [events] to receive user.created, account.locked, session.created, and session.revoked. Each JSON POST carries x-signet-timestamp (Unix seconds) and x-signet-signature (lowercase hex HMAC-SHA256). Verify the signature over the timestamp header, one ASCII dot, and the exact raw request body bytes:

events.secret must resolve to at least 32 characters. Generate one with openssl rand -hex 32, then provide it through an env: or file: reference.

signed_input = x-signet-timestamp + "." + raw_body
expected = hex(HMAC-SHA256(events.secret, signed_input))

Read and preserve the raw body before JSON decoding. Reject the request when abs(now_unix_seconds - x-signet-timestamp) > 300, then compare the supplied and expected signatures in constant time. A changed timestamp or body must fail verification.

session.revoked data contains id, userId, and reason. The reason enum is expired, sign_out, revoke_session, revoke_all, revoke_other_sessions, password_change, multi_session_revoke, session_replaced, two_factor_disabled, user_ban, admin_revoke_all, or admin_revoke. Bulk revocations emit one event per deleted session. Concurrent revokers use an atomic delete-and-return operation, so only the database winner emits for a session.

Delivery is unordered and at-most-once. An admin dead-letter replay can send the same envelope again; persist event.id as the receiver's idempotency key. Signet does not claim an ordered queue or transactional outbox.

account.locked data contains canonical email, lockedUntil, lockLevel, and lastIp. The email may have no user row: unknown addresses accumulate identical lock state to preserve the sign-in enumeration posture.

Abuse controls

Production defaults combine the per-IP rate limiter with persistent per-email lockout, deny-first email admission rules, and a bundled disposable-domain snapshot. The compatibility harness explicitly disables these Signet extensions, so the certified better-auth response surface remains unchanged.

Persistent lockout

[lockout] defaults to five failures in ten minutes. The first lock lasts 15 minutes, doubles for each consecutive lock, and is capped at 24 hours; the escalation level decays after 24 clean hours. State is stored by canonical email string rather than user id, so failures for unknown and real addresses follow the same path across every process and IP. A locked sign-in returns 429 ACCOUNT_LOCKED with standard retry-after plus the existing Signet x-retry-after alias. A correct password never bypasses a lock.

Username/password sign-in checks both the account's email key and a reserved username alias key. Unknown usernames accrue the same alias state, preventing the username plugin from becoming either a lockout bypass or a threshold-based existence oracle.

A completed password reset clears the lock immediately. Operators can clear any canonical address (including one with no user row) with POST /admin/v1/users/unlock, body {"email":"user@example.com"}; the action writes user.unlock to the admin audit log.

Allowlist and blocklist

[email_policy] accepts only exact user@example.com, apex example.com, and *.example.com. A wildcard matches subdomains only, never the apex. Precedence is explicit block, then non-empty allowlist, then disposable blocking, so a block survives an allow typo and a deliberate allow entry can carve through the disposable list.

Every matcher uses one canonical form: surrounding whitespace and a trailing domain dot are removed, case is folded, domain Unicode becomes IDNA punycode, and a local plus-tag is stripped. Provider-specific dot folding is deliberately not attempted; Gmail-style dot aliases remain an operator-visible residual.

Disposable domains

[disposable_email] enabled defaults true for identity creation. The binary bundles snapshot 2026-07-23; a listed domain also catches every subdomain. extra_deny adds local domains and allow supplies carve-outs. list_path replaces the bundled snapshot with a local one-domain-per-line file; an unreadable or malformed file stops boot and names its file and line rather than silently failing open.

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

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.

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

KeyTypeRequiredDescription
adapterOption<String>noStorage adapter: "postgres" (or "memory" for dev). Setting SIGNET_DATABASE_URL forces postgres.
dsnOption<String>noPostgreSQL connection string. Prefer env:SIGNET_DATABASE_URL.
migrateOption<bool>noRun embedded migrations on boot. Default: true.
max_connectionsOption<u32>noConnection-pool ceiling. Default: adapter default.

[delivery]

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

KeyTypeRequiredDescription
modeOption<String>noDelivery channel: "webhook", "smtp", or "none". 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]

SMTP delivery (when mode = "smtp").

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.

[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 signed event POSTs (user.created, session.created, session.revoked).
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.

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

Ed25519 token/OIDC trust-root cache and rotation grace. JWKS rotation is distinct from SIGNET_SECRET cookie/action-HMAC rotation.

KeyTypeRequiredDescription
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. Signet never generates the trust root, creates Unix users, signs host certificates, or provides instant revocation; certificate lifetime is the revocation bound.

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.
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.
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.
clock_skew_secondsOption<u64>noBackdate valid_after for bounded host/workstation clock disagreement. Default: 60 seconds; maximum accepted value: 300.
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.