@guestcraft/auth
Identity and access (ADR 0017, analysis chapters 7, 8 and 18). Better Auth handles identities, sessions, organizations, members and invitations. GuestCraft owns workspaces, their memberships, the audit log, and what a principal may reach. The tables are in @guestcraft/db (db/auth), like every table.
import { createAuth, workspaceScope } from "@guestcraft/auth";
const auth = createAuth({ config, pool, clock, random, logger }); // config from server/config, the app role's poolapp.all("/api/auth/*", (c) => auth.handler(c.req.raw)); // the guarded handler
const principal = await auth.principalOf(request); // user, active organization, workspacesconst scope = principal && workspaceScope(principal, workspaceId);if (!scope?.ok) return forbidden();await database.unitOfWork.run(scope.value, ({ properties }) => properties.list());| Export | What it does |
|---|---|
createAuth({ config, pool, clock, random, logger }) |
Builds Better Auth over the app role’s pool, with every option explicit: nothing is read from the environment, telemetry is off; lockout-audit diagnostics expose only a validated SQLSTATE to logger |
auth.handler(request) |
Serves the guarded identity routes; closed signup blocks legacy member/invitation and organization-directory routes with RFC 9457 404 problems, while direct email signup answers SIGN_UP_CLOSED (403) |
auth.principalOf(request, credentials?) |
The Principal of a session cookie, device token or integration’s credential (an API key, an organization token or a client session; "browser" accepts only the cookie), or undefined |
auth.workspaces |
create; membership changes use the serialized team workflow |
workspaceScope, organizationScope |
The TenantScope a principal may use in a unit of work, or access.denied |
canAccessWorkspace, workspaceRole, isOrganizationAdmin |
The rules behind them |
- Owners and admins of the organization are
adminin every workspace of it; members reach only the workspaces where they have a role. - Only administrators of the organization get
app.organization_idsin their scope; everyone else gets their workspace only. - Better Auth signup is closed by default. New production accounts are created by single-use invitation acceptance, apart from the first operator, whom first-run setup (
auth.setUp, ADR 0020) creates once on an empty instance. LegacysignUp: "open"and email-only"invitation"policies are explicit embedding opt-ins, never enabled by the HTTP runtime. - Sessions are revocable rows. The cookie is
guestcraft.session_token: HTTP-only,SameSite=Lax, and__Secure-over HTTPS. Origin and CSRF checks are always on. - Sign-in attempts are limited in the database, so every instance shares the counters: per client first, by Better Auth’s rule over its
rateLimittable through GuestCraft’s storage (@guestcraft/db/auth), and then per account, by GuestCraft’s lockout in its ownsign_in_lockoutstable (five attempts per fixed 15-minute window, keyed by an HMAC of the folded address under the random key ofsign_in_lockout_key, independent of the auth secret). The lockout is a Better Auth plugin whoseonRequestruns only for requests the per-client rule admits, so a refused request reserves nothing. Both429answers carryRetry-AfterandX-Retry-Afterin seconds. - The failed attempt that locks an account is audited as
team.sign_in_lockedin the workspaces whose team lists the person, without the address or the client’s address. teamreads a member’s lock (sign_in_lock_get) and lifts it (sign_in_unlock), withteam.manageand an audit entry, including a manager’s own lock from an earlier session; an organization administrator’s lock needs an organization administrator.- A Better Auth route reaches the browser only once it is in
ALLOWED_ROUTES(src/guard.ts).
Database bounds
Section titled “Database bounds”Device and sign-in-limit statements use transaction-local server bounds; Better Auth’s
Kysely driver bounds standalone statements and explicit transactions the same way.
Lock waits end after two seconds per acquisition and answer the existing generic 503
with Retry-After: 2; statement timeouts end after ten seconds and answer 500, as in every
unit of work. Decoder/programming bugs remain 500. Nothing is set on a pooled
session or globally on the app role. See ADR 0040
for statement, connection, queueing and unknown-commit limits. A server-answered statement failure
inside a transaction is rolled back before release; a broken Better Auth connection skips rollback and is
destroyed after one client timeout (12 seconds in the runtime). Device and sign-in transactions
still attempt rollback after a lost answer and can wait up to 24 seconds before destruction.
Better Auth’s logger remains enabled; classified database failures are sanitized before they
reach it, and classified adapter failures still reach the runtime’s error boundary even when the
identity library catches them.
pnpm test runs the unit tests. pnpm test:integration starts PostgreSQL 17 and PgBouncer in transaction mode with Testcontainers, and runs Better Auth through the pooler as the app role. The tests cover sign-up, organizations, workspaces, invitations, memberships, row-level security between principals, the audit log, the guard, cookies, cross-site requests and rate limits.
Trusted ingress
Section titled “Trusted ingress”trustedOrigins accepts only exact HTTP or HTTPS origins, such as
https://dashboard.example.com, without a trailing slash. Wildcards fail startup.
When setting ipAddressHeaders, also set trustedProxies to the ingress addresses
or CIDRs, and prevent direct access that bypasses that ingress. For example,
ipAddressHeaders: ["x-forwarded-for"] with trustedProxies: ["10.0.0.0/8"]
walks the forwarded chain from the right and ignores a spoofed prefix. Use the
smallest network that contains the actual ingress.
Device sessions
Section titled “Device sessions”auth.devices signs in the mobile app (ADR 0041):
a browser session issues a one-time code for an app’s request whose redirect scheme is in
mobileAppSchemes; the app exchanges it with its PKCE S256 verifier for a gcd_… token. Codes and
tokens are stored as SHA-256 digests (db/auth), and each code serves one attempt. A request with
Authorization: Bearer gcd_… is authenticated by that token only: principalOf builds the same
principal as for the person’s cookie session, from their live organization access. A device
token’s request is admitted like an API key’s, by the same budgets of the instance (see below; one
keyBudgets that createAuth gives both): remembered, a token spends its own 600 requests a
minute, at most 100 tokens of one person; otherwise it is a guess. Both lookups of a token, the
principal’s and the one of the device session routes (list, revoke), go through the one lookup
function that the shared budgets give device tokens (lookups, below), so each can answer
auth.rate_limited, and they share a token’s guess being looked up.
isBearerRequest tells the composition which requests carry no cookie that the origin policy
protects: those with a device token or an API key. Signing out other sessions, or changing the password with that option, also revokes the
person’s device sessions and deletes their codes not yet exchanged; the store locks the person
first in every exchange and revocation, so they run one after the other. A code is exchanged only
while the browser session that allowed it exists, whichever sign-out ends that session: the
exchange shares that session’s key before it locks the code, so a sign-out either comes first or
waits for it. The lock order of every writer is in ADR 0041.
A browser session can also pair a phone with a QR code (createPairing): it issues a gcp_…
pairing code, valid two minutes, with the instance’s /mobile/pair page as the QR code’s address
and the code in its fragment; the app exchanges it with a device name of its own
(exchangePairingCode), checked before the code is looked up. Pairing codes are rows of the same
store, so every rule above holds for them; only the browser session that issued a pairing reads
it (pairing) or withdraws its unused code (withdrawPairing), which locks the person first.
API keys
Section titled “API keys”Authorization: Bearer gc_live_… (or gc_test_…) authenticates its request by that key alone
(ADR 0040). authenticate finds the key
by the SHA-256 of its secret through db/auth‘s narrow credential function, then builds the
principal of its creator from their live access, reduced to the key’s workspace and narrowed to its
permissions and properties; organization roles never reach a key. A revoked or expired key, or one
whose creator no longer reaches the workspace, authenticates nothing and is audited as
api_key.denied. Every key request is admitted before its lookup by the budgets of
key-budgets.ts, per instance, so every caller gets auth.rate_limited the same way: a key that
the instance remembers as authenticated (at most 10,000 and 100 of one workspace, each until a day
unused) spends its own 600 requests a minute; any other key is a guess and takes, before its lookup
and synchronously, a permit of its client address (one guess looked up at a time), a permit of the
instance (half the connections of the pool it is given), one of its address’s 30 failed key
requests a minute and, for IPv6, one of its /48’s 300, which neighbours within that /48 share. The
permits come back when the lookup ends; the failed requests are given back when the key
authenticates and finds a place to be remembered (its workspace’s least recently used
key’s, or the instance’s, once unused for a minute), or the lookup fails, and kept otherwise: a key
without a place is refused like a spent one. Others’ failures never refuse a remembered key; a
request without a known address takes an instance permit only. A lookup that fails gives back what
its request took and throws its failure as it is: a credential query that did not finish (a lock not
granted within the credential function’s 2 seconds, no connection, a connection lost or given up)
is the store’s retryable InfrastructureError, which the API answers 503 with Retry-After.
lookups<T>() gives each kind of key its map of the guesses being looked up: a request of a key
whose guess is in it, from any address, takes no permit and no failed request, waits for that
lookup, at most 100 waiting per lookup besides the one looked up (the next is refused), and gets its outcome. Once the key
authenticated, each spends the remembered key’s own budget; otherwise each gets the lookup’s 401 or
429, without spending a failed request of its address, or its failure. Remembered keys are looked up
for every request.
The gateway’s lookup returns the principal, in its workspace’s share of the remembered keys, or
undefined.
A key’s lookup, which the requests arriving with it share, finds the key’s facts (FoundKey): its
principal, owner, allowed address ranges and expiry. Each request is then admitted on its own: its
own client address against the ranges, the instant it arrived against the expiry, its own charge of
the key’s budget. A refused request answers 401, is audited as api_key.denied (ip_address or
expired), records no last use, and costs one failure of its own address only, never the key’s
budget or place; a remembered key keeps its facts, so it refuses an outside address before any
lookup. The client address is read with all 128 bits of an IPv6 address (Better Auth reduces it to
its /64 for its own limits); only the budgets group IPv6 addresses, by /64 and /48.
auth.refused audits refusals after authentication (another workspace, a 403), at most once a
minute per credential. Like a device token, an API key never makes a browser session (browserSession),
nor does any other bearer in the instance’s credential namespace (gc_…): a first-party guest
session’s token, or a malformed or unknown secret, authenticates nothing, even next to a valid cookie.
auth.creatorGrants reads the live grants of keys’ creators for the key list’s creator_access,
one creator at a time on the auth pool.
Organization tokens and client sessions
Section titled “Organization tokens and client sessions”Organization tokens (gc_org_…) and client sessions (gc_cst_…) are the other credentials of
integrations (ADR 0047). integration-credentials.ts is
the one gateway of all three kinds: credentialGateway(kind, options, audit) admits each request
by the budgets above, looks its credential up by digest, checks its status, allow-list and expiry,
records last use, and audits refusals as <kind>.denied through one shared refusalAudit. Each
kind (apiKeyKind, organizationTokenKind, clientSessionKind) declares how it is found, whom it
authenticates now and whose share of the remembered keys it takes. An organization token’s
principal is its creator in every workspace of the token they still reach, with the token’s
permissions narrowed there, and the organization’s share; a client session’s is the user of its
minting key or token in one workspace, with its grant narrowed to what that credential may still
do (an inactive minter is refused with the reason minter), in a share per workspace apart from
its API keys; the principal also carries the session’s scope (clientSessionScope), which the API
enforces on each operation. auth.creatorWorkspaceGrants reads tokens’ creators’ grants for their
creator_access.
OAuth applications
Section titled “OAuth applications”The identity runs Better Auth’s OAuth provider (oauth-provider.ts): authorization code with PKCE
S256, rotating refresh tokens, opaque access tokens (gc_oat_…, refresh gc_ort_…, stored as the
SHA-256 of the whole secret) for the instance’s two protected resources (<base>/api/v1 and
<base>/api/mcp, from the configured base URL), dynamic registration bounded by
registrationPolicy (oauth-applications.ts), and no OpenID Connect. Only the server’s consent
completion (oauth-consents.ts, in process) gives a consent its reference, the grant it stored, for
the session and application it verified; every other authorization shows the consent page. An
access token is the credential kind oauth_application of the shared gateway: found by digest
through oauth_token_grant, admitted only at the resource of its request’s path, each request on
its own, and its principal is the member who authorized it, in the grant’s workspace, narrowed to
the grant, the token’s scopes and the member’s live access (an ineffective grant answers 401 with
the audited reason creator_access), in a share of the remembered keys per workspace apart from
its API keys. refreshPolicy, a Better Auth plugin that runs after the provider’s limit per client
address, refuses a refresh under a revoked or ineffective grant before Better Auth rotates it, and
oauthIntrospection makes introspection answer active: false for one. The guard opens the
protocol’s routes only (authorize, and OAUTH_PROTOCOL_ROUTES of core/integrations: token,
register, revoke, introspect), never the provider’s management of clients, resources or consents;
their cookie-less POSTs carry no cookie that the origin policy protects (isBearerRequest).
Team management
Section titled “Team management”GuestCraftAuth.team(request, command) manages workspace/organization membership through
token invitations, live authorization and transactional audit. Closed production signup
blocks legacy Better Auth invitation/member writes. New accounts, apart from the first
operator of setup, register only through a single-use invitation capability; existing accounts require matching session identity.
See ADR 0030 for guarantees and limits.
cookieOriginPolicy(trustedOrigins) checks exact configured origins on every unsafe
method, falling back only to Fetch Metadata same-origin or none when Origin is
absent. The team gateway uses it directly; server/http injects it at the shared API
boundary. See ADR 0020. Better Auth keeps its own additional identity CSRF checks.
auth.tenancy(request, command) owns browser-only organization provisioning, workspace creation/settings/archival and organization Activity. Writes require a trusted Origin and live grants under the organization lock. Only explicit instance administrators create organizations; first-run setup grants that role to its operator, and migration 0044 backfills only the actor of instance.setup_completed. auth.directory(principal) lists reachable workspaces across live organization memberships, including archived recovery entries. A browser workspace header may select a workspace in another organization without changing another session. Archived workspaces are excluded from operational principals, including device tokens, API keys, organization tokens and client sessions.
The HTTP Activity routes use auth.organizationActivity(request, query) and
auth.workspaceActivity(request, query) to authenticate the browser before invoking their
cursor decoder. Organization Activity requires no selected workspace or active organization;
each command still checks live authority in the requested organization or workspace. Exports
(audit_export, organization_audit_export) are reads without a cursor that also record
audit.exported. API-key
denial audits preserve revoked and expired ahead of workspace_archived. The shared access
reader drops inaccessible workspaces and supplies a role and derived permissions for every
remaining entry, including built-in roles; the browser directory uses those grants directly.
Account security
Section titled “Account security”Two-factor sign-in, passkeys, organizations’ security policies and password reset links
(ADR 0053). Better Auth’s two-factor plugin and
@better-auth/passkey (both 1.7.10) serve the factors; account-security.ts is GuestCraft’s
plugin around them: every session records how it was signed in (signInMethod), a code checked
on an existing session raises it (a step-up) under the account’s failure lock, recovery codes
are stored and compared as SHA-256 digests (recovery-codes.ts), a remembered browser stays
remembered at most 30 days after its code, changing factors needs a session that proved one, and
every factor change is audited in the person’s organizations. Passkeys use the base URL’s host as
relying party and accept only configured origins on it.
assurance.ts applies the policies to every browser request (the one cookie-session reader that
the principal, the team, tenancy, Activity, account security and device pairing share): a
session older than a policy of the person’s organizations allows is signed out; in an
organization whose requirement it misses, past the grace period, it answers
auth.step_up_required. A device token holds the method of the browser session that paired it
and answers auth.reauthentication_required once its organization requires more. Integration
credentials are not judged.
auth.security (security.ts) serves the administration (policy_get, policy_update,
compliance, password_reset_issue, password_reset_withdraw) from a browser session that meets
the organization’s policy, with a trusted Origin for changes, and the public redemption of reset
links. Links are 32 random bytes shown once, stored as SHA-256, valid 60 minutes, within budgets
kept in the database (5 per person and 30 per issuer an hour, 10 redemptions per address a
minute). auth.browserStandings(request) gives the dashboard each organization’s standing.
Enterprise identity
Section titled “Enterprise identity”Single sign-on and SCIM (ADR 0055), around Better
Auth’s @better-auth/sso and @better-auth/scim 1.7.10:
auth.enterpriseIdentity.execute(request, command)administers an organization’s domains, connections, SCIM tokens and group mappings (EnterpriseIdentityCommand), for its owners and administrators from a browser session that meets its policy;createAuth‘sresolveTxtlooks up the domains’ TXT records, andauth.checkDomains()checks again those due (every 24 hours).sso.ts:POST /api/auth/sso/discover(by e-mail address only, every answer paced to the same floor, a miss starting a decoy through Better Auth that runs a hit’s statements and keeps nothing), the nonce added to every OIDC sign-in and checked against the verified ID token, the browser binding of every SAML sign-in (a one-time__Host-cookie the ACS takes once) and its Destination and validity checks on GuestCraft’s clock,resolveUser’s refusals and links,POST /api/auth/sso/link(an existing password account, confirmed by a fresh sign-in), the session’ssso/sso_mfaassurance and organization, and the membership of a first sign-in.saml-messages.tsreads the AuthnRequest and response fields these checks use.sso-provider.ts: the provider rows GuestCraft writes (PKCE, discovered endpoints, signed SAML assertions, pinned certificates). Better Auth’s provider and domain routes stay closed.scim.ts:/api/auth/scim/v2authenticated bygc_scim_tokens; each request runs in one transaction that Better Auth’s queries join (joinTransactionindatabase.ts), with the reconciliation of every person it changes; the owner is never changed.