Skip to content

0055. Enterprise identity: verified domains, OIDC and SAML single sign-on, SCIM provisioning

  • Status: Proposed for owner review
  • Date: 2026-11-01
  • Extends: ADR 0017, ADR 0030, ADR 0036, ADR 0040, ADR 0041, ADR 0053
  • An organization owner or administrator adds a domain, publishes the TXT record GuestCraft shows, and verifies it. A domain belongs to one organization at a time. GuestCraft checks the record again every 24 hours; when it is missing, the domain is failing with a visible warning for 7 days, then lapses: single sign-on for its addresses stops and the sessions it began end. Releasing a domain has the same effect at once. Every step is audited.
  • They create an OIDC connection (issuer, client ID, secret) or a SAML connection (IdP entity ID, SSO URL, signing certificates), choose the proved domains it serves, the workspace roles of a first sign-in, and whether the identity provider’s second factor counts. GuestCraft shows the redirect URI, or the SAML entity ID, ACS and metadata URLs, to configure the identity provider.
  • A person types their e-mail address on the sign-in page; an address of a domain served by an enabled connection continues at that organization’s identity provider, any other address gets the same answer as an unknown one. No list of connections or organizations exists, and an answer’s timing does not tell either:
    • Every discovery answer comes 300 ms after its request at the earliest (a hit usually takes 15 to 25 ms). A timer can wake early, so the deadline is checked again after each wait, on the monotonic clock GuestCraft injects, and waited on again (at most 8 waits, so a broken timer cannot hold an answer).
    • A miss stores what a hit stores, through the same path and the same parameterized statements, so a server that stores slowly under load delays both alike, beyond the floor too. Every start, OIDC or SAML, runs: Better Auth’s read of the provider row and its write of the sign-in’s state (a verification), each in a transaction of its own; then one GuestCraft transaction that inserts the start’s flow (sso_flows) and drops expired flows. A miss calls a decoy endpoint of Better Auth (/sign-in/sso-decoy, closed to clients like /sign-in/sso): it reads a provider no connection has, keeps its state through Better Auth’s generateState with a provider reference of a real one’s size and fields, and builds an authorization URL. Discovery then inserts the decoy’s flow, under a connection identifier no row has, and the statement that drops expired flows also removes the decoy’s flow and state; a hit runs that statement with nothing of its own to remove.
    • Why no foreign key binds a flow to its connection (migration 0075). The nonce’s foreign key made every OIDC start take a key-share lock on its connection’s row, and gave a decoy no row to name but a real one: the first decoy named the oldest connection, another organization’s, so a lock on that row delayed misses only, and with no connection at all a miss inserted nothing. A sentinel connection would have needed a sentinel organization in every organization’s tables. The binding is already checked where it matters: the callback takes a nonce only for the connection whose callback presents its state, and the ACS refuses a binding of another connection. A deleted connection’s flows sign nobody in and expire within 10 minutes. Without the key, no start locks a connection’s row, its own or another organization’s.
    • Failures. A decoy fails where a hit fails, the same way: Better Auth logs a failure of its own storage and answers 500, discovery then rethrows the store’s InfrastructureError (authOperation); a failure of the flow’s transaction is thrown as it is. Nothing is swallowed on either side.
    • The decoy is never sent, and no other transaction ever sees its flow. No browser holds its state, nor the signed cookie a callback also needs, and a crash before the flow’s transaction leaves only that state, which expires after 10 minutes as a hit’s does. It runs after the client’s discovery budget is spent, and is neither logged nor audited.
    • Better Auth limits neither /sign-in/sso nor the decoy’s endpoint per client: only discovery reaches them, after spending the client’s budget, and that limit was a write a miss would not have made.
    • SAML starts store what OIDC starts store: Better Auth keeps no record of the AuthnRequest (enableInResponseToValidation: false). That record duplicated GuestCraft’s browser binding, which keeps the request’s ID with its connection and RelayState and refuses a response that answers another request. The tradeoff: Better Auth’s independent request-ID row, its check of the provider reference stored with it, and its 5-minute lifetime are gone; GuestCraft’s binding (connection, request ID, RelayState, one-time __Host- cookie) and its 10-minute lifetime are now the only replay binding of a response to its request. Better Auth still checks the signature, audience, recipient, timestamps, the response’s shape and the provider reference in the RelayState’s own state, and reserves each assertion ID once. Integration tests refuse a response that answers another request, carries another RelayState, or is posted to another connection’s ACS; each fails when GuestCraft’s check of it is removed.
    • The auth tests check it at three levels. Real PostgreSQL traces: an OIDC hit, a SAML hit and a miss run the same normalized SQL in the same 23 round trips, on an instance without any connection, with disabled connections only, and among several organizations; a miss’s state has a hit’s fields and size, and nothing of it remains. No start waits on a lock of every connection’s row, or on their uncommitted deletion. A lock on the provider, verification or flow table fails a hit and a miss with the same error and the same log lines. With every write of a state or flow slowed to 400 ms, a miss and a hit take the same time (a unit test on discovery’s timer, and the integration test). The integration test also measures 30 hits, 30 misses and 30 misses of another domain in turns: their medians differ by under 5 ms beyond what the two groups of misses differ by; a one-sided Mann-Whitney test at a 0.001 level does not find hits slower than misses by more than 5 ms; and a one-sided Fisher exact test at a 0.001 level does not find more hits than misses over the floor plus 10 ms, the tail where storage that outlasts the floor shows.
    • Measured: medians within 0.3 ms; without the floor, hits took about 7.5 ms more at the median and the rank test’s z was over 6. Before misses stored anything, a 300 ms stall in 18% of the writes of a state or nonce put 10 hits and no miss after the floor plus 10 ms: the tail test failed (p = 0.0004) while the median and rank tests passed. With the decoy, the tail test passes (9 hits and 18 misses, p = 0.996). With every write of a state or flow taking 400 ms, hits took 0.7 ms more than misses at the median: what remains of a hit’s in-process work (an OIDC start’s provider configuration and secret; a SAML start’s AuthnRequest) once storage outlasts the floor.
  • A first sign-in makes the person a member of the organization with the connection’s workspace roles. An existing account with a password is linked only after its owner signs in with that password and confirms. The organization’s owner keeps their own sign-in whatever happens to SSO, DNS or SCIM.
  • An organization creates SCIM tokens (shown once, hashed, expiring, revocable, audited) and gives its identity provider the SCIM 2.0 base URL. Provisioned people become members; provisioned groups map to workspace roles; deactivating or deleting a person ends every way they have in: membership, browser and device sessions, pending device codes, remembered browsers, the API keys and organization tokens they created there, and the OAuth applications they authorized there (ADR 0047), with their tokens.

Better Auth’s plugins, GuestCraft’s rules

Section titled “Better Auth’s plugins, GuestCraft’s rules”

@better-auth/sso and @better-auth/scim 1.7.10 (MIT, the catalog’s Better Auth version) serve the protocol endpoints: the OIDC callback, the SAML service provider (metadata, ACS), and SCIM 2.0 /Users, /Groups and discovery. GuestCraft keeps every decision that involves organizations, roles, assurance or credentials, and closes the rest with the guard (ADR 0017):

Route Status
POST /api/auth/sso/discover, POST /api/auth/sso/link-preview, POST /api/auth/sso/link GuestCraft’s own
GET /api/auth/sso/callback/{sso_id}, POST /api/auth/sso/saml2/sp/acs/{sso_id}, GET /api/auth/sso/saml2/sp/metadata Better Auth, wrapped
`/api/auth/scim/v2/(Users Groups)[/{id}], ServiceProviderConfig, Schemas, ResourceTypes`
/sign-in/sso, /sso/register, /sso/providers, /sso/get-provider, /sso/update-provider, /sso/delete-provider, /sso/request-domain-verification, /sso/verify-domain, SLO closed (404)

Connections, domains and SCIM tokens are administered through the public API (enterprise_identity tag) by organization owners and administrators, from a browser session that meets the organization’s policy (auth.step_up_required otherwise), like ADR 0053’s policy.

  • Only a registrable domain is stored (one label below a public suffix, private suffixes such as shared hosting included; tldts), so one organization holds a domain and all its subdomains. A partial unique index (organization_domains_owner) holds the fence: a proved domain (verified or failing) has one organization; proofs of one domain are serialized by an advisory lock.
  • The record is _guestcraft-verification.<domain> TXT "guestcraft-verification=<token>", the token 128 random bits; the strings of a TXT record are joined before the exact comparison (RFC 7208 §3.3). The runtime resolves through node:dns with a 3-second timeout; a name without the record proves nothing, a failed lookup changes nothing but the time of the attempt: the domain is looked up again an hour later, so one unreachable name server cannot hold the head of every run.
  • Checks: every proved domain whose last check is 24 hours old, at most once an hour per process on ordinary traffic, daily from Vercel’s cron, and from GET /api/internal/domains/check. Missing record: failing from then on (audited once), 7 days of grace, then lapsed (audited). A record found again during the grace days restores verified (audited).
  • Release or lapse: the connection stops serving the domain (Better Auth’s provider row lists only proved domains) and the browser sessions and devices that the organization’s SSO began for people on that domain end. Handover to another organization happens only after release or lapse, with that organization’s own new proof. Existing accounts and their links never move.
  • Identity. An account is bound to (connection, subject): Better Auth’s account key is the connection’s providerId and the OIDC sub or SAML NameID. Implicit linking by e-mail stays off. A connection’s identity provider (OIDC issuer, SAML entity ID) never changes: another provider could reuse a subject. Deleting a connection removes its links and the SSO sessions they began.
  • Refusals (?error= on the sign-in page): an address outside the connection’s proved domains; a person the organization’s SCIM deactivated or deleted; a linked person who is no longer a member (an administrator removed them: single sign-on never undoes that, an invitation does); an account another subject of the same connection holds; any other existing account, unless it has a password (then the link waits) or the organization’s SCIM created it with no other credential and no other organization (then it is linked at once).
  • Linking an existing password account: GuestCraft keeps the request (digest of a 256-bit token, 15 minutes, one per person) and sends the browser to /sign-in/sso-link#token=. The account’s owner signs in there with their current password and second factor; only a session created after the request confirms it (POST /api/auth/sso/link). The page first shows which organization and connection ask (POST /api/auth/sso/link-preview, nothing about the account). Confirmation checks again that the connection is enabled and serves a proved domain of the address, and that the organization’s SCIM has not deprovisioned the person since. Its times are the database’s, since Better Auth stamps sessions with the system clock.
  • OIDC. Every provider row is written by GuestCraft with PKCE (S256), the endpoints of the issuer’s discovery document (fetched at configuration, HTTPS on a public host or a trusted origin), openid email profile (no refresh token), client_secret_basic or _post only, no identity-provider-initiated flow. Better Auth sends no nonce and reserves the parameter, so discovery adds a fresh 256-bit nonce to the authorization URL and keeps it by the digest of the flow’s state for 10 minutes; the callback takes it once and resolveUser compares it in constant time with the verified ID token’s nonce (OIDC Core §3.1.2.1, §3.1.3.7). Better Auth checks issuer, audience, azp, signature and the UserInfo subject. Identity-provider tokens are stored encrypted with the auth secret.
  • SAML. Signed assertions required, pinned IdP certificates, requireTimestamps, InResponseTo checked against GuestCraft’s browser binding (below), one assertion per response, audience, recipient and destination checks, replay refused by Better Auth’s single-use reservation of the assertion ID, deprecated algorithms rejected, 60 seconds of clock skew. Identity-provider-initiated SSO is not supported: an unsolicited response, or one without the RelayState of a sign-in GuestCraft started, is refused. Better Auth keys that reservation by a digest, not a UUID: verification.id becomes text.
  • SAML login CSRF. The ACS is a cross-site POST, which Better Auth does not bind to a browser (its RelayState cookie is SameSite=Lax, so it skips the check). When discovery starts a SAML sign-in, GuestCraft sets a one-time 256-bit token in a __Host-guestcraft.saml_flow cookie (Secure, HttpOnly, SameSite=None, Path=/, 10 minutes) and keeps its digest with the digest of the AuthnRequest’s RelayState, the request’s ID and the connection (sso_flows). The ACS takes that binding once, whatever it then answers, and refuses (SSO_STARTED_ELSEWHERE, before Better Auth) a response without the cookie, of another connection, with another RelayState, or answering another request (InResponseTo). A response a sign-in started in one browser, posted from another, signs nobody in; the answer clears the cookie. A browser keeps one SAML sign-in in flight: starting another replaces the first.
  • SAML Destination and time. Core §3.2.2 makes a response’s Destination optional, and Better Auth checks it only when present. GuestCraft requires it and compares it exactly with the connection’s ACS URL. Better Auth checks NotBefore/NotOnOrAfter on its own wall clock (Date.now(), no injection point in 1.7.10), so GuestCraft first checks the assertion’s Conditions and every bearer SubjectConfirmationData NotOnOrAfter on its injected clock, with the same 60 seconds of skew: valid from NotBefore − 60 s, until before NotOnOrAfter + 60 s. Either refusal is SSO_RESPONSE_INVALID. Better Auth’s wall-clock check then still runs: an instance whose clock disagrees with its database’s by more than the skew refuses responses.
  • Assurance (on ADR 0053’s model). A session records sso, or sso_mfa with the claim that justified it (amr:<value> or acr:<value>), and the organization whose connection signed it in. sso_mfa requires the connection to trust its identity provider’s second factor (off by default) and a listed value: OIDC amr (RFC 8176) or acr, SAML AuthnContextClassRef. It meets second_factor in that organization only, never passkey; in another organization it is a plain sso. A device paired by such a session holds the same organization.
  • Provisioning. After Better Auth’s own transaction, GuestCraft makes the person a member (never admin or owner) with the connection’s workspace roles (built-in or custom, of active workspaces of the organization, at every property or at selected ones, re-checked when granted), in one transaction under the organization’s lock, audited as sso.member_provisioned; every sign-in is audited as sso.signed_in with its assurance. A failure ends the new session. An existing member keeps their roles. A person SSO created has no password; ADR 0053’s reset by the CLI gives them one, so break-glass covers them too.
  • One connection per organization: its id and provisioning domain are the organization’s. Tokens: gc_scim_ and 256 random bits, SHA-256 digest only, 1–730 days, at most 5 live, revocable, last use written at most every five minutes; issuance, revocation and refused uses are audited.
  • Scopes. A token holds the scopes chosen when it is created, never changed: one or more of scim.users.read, scim.users.write, scim.groups.read, scim.groups.write (a database check keeps them a non-empty subset; scim.token_created records them). They are the only scopes Better Auth’s plugin sees, and it refuses every resource route they do not cover (403); the discovery endpoints need none. The API requires the choice, with no default: a token’s power is an explicit decision. The screen proposes all four, which Microsoft Entra ID and Okta need.
  • Every SCIM request runs in one GuestCraft transaction that Better Auth’s queries join (joinTransaction: its transactions become savepoints). The plugin’s writes and GuestCraft’s reconciliation of each person it changes commit together; a refused request changes nothing. Nothing else may take a connection meanwhile (a transaction pooler’s only connection is held): Better Auth’s per-client limit is off for /scim/v2/** and GuestCraft spends its own budgets before the transaction; Better Auth’s schema check is settled before it.
  • Budgets, each 600 requests a minute and independent of the others: the client address’s, spent first and before anything is looked up; then, for a token GuestCraft issued (found by its digest in a short read before the request’s transaction), the token’s, so one token behind many addresses is limited; and, for a live token, its organization’s, across all its tokens. A revoked or expired token spends its own budget only, so a leaked old token cannot exhaust its organization’s. A spent budget answers 429 in SCIM’s error format with Retry-After, before the transaction, and changes nothing.
  • Addresses. A person whose address is on a domain another organization proves is refused (400 invalidValue), whether created or changed: SCIM of one organization never provisions the people another organization signs in.
  • Reconciliation of an active person: member if they are not one; exactly the workspace roles of their active groups among the memberships SCIM granted (scim_managed_memberships), the strongest role per workspace when groups disagree (most permissions, then built-in, then order); a membership an administrator granted is never changed. Changing a group’s mapping reconciles its members.
  • Properties. A connection’s default grant and a group’s mapping hold their role at every property of the workspace or at selected properties, as a member’s (ADR 0036): stored as property_scope and rows of the workspace’s properties (composite keys, a deleted property leaving the grant; a selection left empty grants nothing). Up to 100 properties, each once, of the grant’s workspace. When several of a person’s groups grant one workspace, the strongest role wins and holds the properties of every group that grants that same role, every property absorbing a list; a weaker role’s properties never widen it. A first sign-in and SCIM reconciliation write the membership’s property_scope and selected properties; a change of a mapping’s properties changes the memberships SCIM granted, never an administrator’s. The configuring owner or administrator holds every property, so a grant never exceeds its author’s access; SSO and SCIM never change an existing administrator-granted membership, so they never widen one either.
  • Deactivation and deletion (active: false, DELETE): the membership with every workspace role, every browser session and remembered browser of the person, their device sessions and pending codes in the organization, the API keys and organization tokens they created there, and their OAuth applications’ grants there, with the provider’s access and refresh tokens and consents under them, end in the same transaction (scim.user_deprovisioned, with counts): a reactivated person, even one a group grants the workspace again, must authorize an application again. That holds in archived workspaces too, which no runtime policy reaches: a narrow SECURITY DEFINER function (migration 0074, revoke_person_credentials, granted to the API role only) revokes the person’s keys and grants in every workspace of the organization, so restoring a workspace never brings one back. DELETE keeps the account and Better Auth’s tombstone of the SCIM link (re-provisioning with the same externalId links it again); it never erases anything: erasing a staff account is a separate workflow. A deprovisioned person cannot come back through a first SSO sign-in.
  • SCIM never deactivates or deletes the organization’s owner: either answers 400 mutability and changes nothing. It still keeps the name and e-mail address of an owner it created up to date. With the owner’s own password (and ADR 0053’s reset by the CLI), this is the break-glass way in that SSO, DNS and SCIM cannot remove.

The guest personal-data catalog covers the guestcraft workspace tables; these tables are staff identity in guestcraft_auth and hold no guest data. Their classification:

Table Holds
organization_domains Organization identifiers: a registrable domain and its verification token (not a secret: it is published in DNS)
sso_connections, ssoProvider, sso_connection_grants and their properties Configuration; the OIDC client secret only sealed with ENCRYPTION_KEYS, SAML certificates are public
sso_flows, sso_link_requests Short-lived secrets, stored as digests, deleted when used or expired
scim_tokens Secrets as SHA-256 digests, with name, scopes and use times
scimUser, scimSubject, scimConnectionBinding, scimIdentityTombstone, scimProjectionGrant, scim_managed_memberships Staff identifiers: the link between a staff account and its identity provider’s externalId
scimGroup, scimGroupMember, scim_group_grants and their properties Directory group names and memberships of staff

Erasing a staff account is not part of this decision (SCIM DELETE deprovisions and keeps a tombstone).

  • Migration 0070 adds the plugins’ tables with Better Auth’s names, GuestCraft’s tables (sso_connections, organization_domains, sso_connection_grants, sso_nonces, sso_saml_flows, sso_link_requests, scim_tokens, scim_group_grants, scim_managed_memberships), the session columns ssoOrganizationId and ssoAssuranceClaim, device_sessions.sso_organization_id, and changes verification.id to text: a contract migration (a runtime before it compares invitation identifiers with it as UUIDs).
  • Migration 0075 replaces sso_nonces and sso_saml_flows with sso_flows, without a foreign key to the connection: a contract migration (a runtime before it writes the dropped tables). Sign-ins in flight when it runs start again.
  • Tests: the independent identity provider simulator (@guestcraft/identity-provider-simulator, written from OIDC Core/Discovery, RFC 6749/7636 and SAML 2.0, sharing no code with the verifier) drives OIDC and SAML sign-ins and every malformed response in PostgreSQL integration tests and the browser test; SCIM requests run against the real handler and database.
  • An internal identity provider on a private network must be listed in GUESTCRAFT_TRUSTED_ORIGINS: Better Auth fetches token, JWKS and UserInfo endpoints only on public hosts or trusted origins.
  • The OIDC client secret is sealed with ENCRYPTION_KEYS by the integration vault (ADR 0021, ADR 0040: a per-value data key wrapped by a key derived for this kind of value, AES-256-GCM), bound to the instance, the organization and the connection’s sso_ identifier. The provider row holds only the envelope (clientSecretSealed), and the database refuses a plaintext clientSecret (ssoProvider_client_secret_sealed). Better Auth reads the row through an output transform of that column: the secret is opened only while the OIDC callback of that same connection runs, for the code exchange, and every other read (discovery, sign-in start) carries no secret. An envelope that does not open under the callback’s binding fails closed: the identity provider refuses the exchange. The API never answers the secret (client_secret_set only), and nothing logs it. An instance without ENCRYPTION_KEYS answers server.unavailable to creating or changing an OIDC connection.
  • Rotation and earlier plaintext. The credential maintenance (reencrypt, ADR 0044) rewrites the sealed secrets to the current key version with every other integration secret. A database that ran an earlier version of migration 0070 may hold plaintext secrets: the rule is added unvalidated, every write obeys it at once, and the maintenance first seals what remains and then validates it. Until then, such a connection signs nobody in: a plaintext secret is never passed to Better Auth.
  • Enforcing SSO (refusing password sign-in for members on a proved domain) is not built: it needs the owner to choose who is exempt beyond the owner, and members keep their passwords until then. SSO is offered, never imposed.
  • Encrypted SAML assertions, signed AuthnRequests, SAML Single Logout, IdP metadata upload, and private-key client authentication are not offered.
  • Better Auth’s own provider registration and domain verification routes. They verify per provider with the system clock and resolver, accept a bare token, never re-check, and let a provider start a sign-in by providerId, domain or organization slug (enumeration). GuestCraft owns domains and writes the provider rows.
  • The plugin’s organization provisioning. It runs after Better Auth’s transaction, grants only Better Auth’s member/admin, and knows no workspace roles or audit.
  • Reconciling SCIM after the plugin commits. A failure between the commit and the reconciliation would leave a deactivated person with their devices and keys; the joined transaction makes it all or nothing.
  • Triggers maintaining workspace memberships from staging rows. Atomic too, but business rules in PL/pgSQL, apart from the stores and their tests.