{ userId, actor, scopes },
and hands every route the same auth context. Modules consume that context; they
never reinvent it. Read Auth and identity first
for the concepts (actor types, identity vs authorization, the token baseline).
This guide is the how-to: the seams you actually touch when you extend auth.
There are two seams, and they are independent:
The first seam is provider-agnostic: it is how you swap in a different identity
source without touching module code. The second is the concrete default the
first-party starters wire into it. You can use one without the other.
The provider seam
createApp({ auth }) takes a VoyantAuthIntegration. It is four optional
callbacks, each a distinct extension point:
The
resolve callback is the seam that matters most. The middleware tries
internal keys, then voy_ API keys, and only then calls resolve for
cookie/provider auth. Whatever you return becomes the request’s identity.
The return type is VoyantRequestAuthContext, which requires both userId and
actor. The actor requirement is enforced at compile time on purpose:
requireActor is fail-closed, so a context without an actor would 401 every
protected route (the old default of silently assuming "staff" was removed).
/v1/admin/* requires staff; /v1/public/* requires customer, partner,
or supplier. Your resolver must return the right one.
resolve receives { request, env, db, ctx }, so you can do a database lookup
or call out to your provider. Module and route code never sees which credential
authenticated the request; it only reads the normalized context through helpers
like requireUserId(c) and requireActor(...).
Configure Better Auth
The first-party starters back that integration with Better Auth.createBetterAuth(options) from @voyant-travel/auth/server builds a configured
instance. Import it from the ./server subpath; the package root (.) stays
edge-safe and does not pull in Better Auth.
The factory ships these defaults, so you only override what you need:
- Drizzle adapter over the shared
iamschema (user,session,account,verification,apikeytables). - Email and password enabled:
minPasswordLength: 8,maxPasswordLength: 128,requireEmailVerification: true,revokeSessionsOnPasswordReset: true. - Always-loaded plugins: the API-key plugin
(
defaultPrefix: "voy_",apiKeyHeaders: ["authorization"],requireName: true) and email OTP (otpLength: 6,expiresIn: 600seconds). - Session cookie cache on by default with a 5-minute TTL: session data
rides in a short-lived signed cookie so
getSessionskips Postgres on most requests. The trade-off is that a revoked session can stay usable for up to the TTL. secretread fromBETTER_AUTH_ADMIN_SECRETby default, or fromBETTER_AUTH_CUSTOMER_SECRETwhenrealm: "customer"is selected. Each must be at least 32 characters, and the two realm secrets must be independent.
Google social login
Google is the one social provider wired in by the factory, and it is enabled only when bothGOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are present in
the environment. Set those two variables and Google sign-in turns on; leave them
unset and it stays off. There is no generic “add any social provider” config
flag. To add another OAuth provider, pass a Better Auth plugin (see the next
section).
Distributed rate limiting
Pass asecondaryStorage (a KV or Redis store) and the factory enables a
distributed rate limit (window: 60, max: 100) backed by that storage.
Without it, no Better Auth rate limit is configured by default. You can also
pass an explicit rateLimit to override.
Better Auth’s own rate limit is separate from the platform’s app-wide rate
limit (
createApp({ rateLimit })), which by default throttles /auth/* POSTs
and unauthenticated public writes at the Hono layer.The single-tenant signup guard
The factory installs adatabaseHooks.user.create.before guard: once any user
exists, new admin-surface sign-ups are rejected with “Sign-up is disabled. Ask
an admin to invite you.” The very first user to register is provisioned as the
super-admin. This makes a fresh deployment self-bootstrap and then lock down.
Customer-facing OTP signup endpoints can still create users (they are stamped
with non-admin surfaces). Tune or disable the guard through
disableSignupWhenUsersExist and customerSignupSurfaces.
Add a Better Auth plugin
The factory does not expose a flag for every Better Auth feature. Instead it forwardsoptions.plugins (appended after the always-loaded API-key and OTP
plugins) and options.extraSchema (extra Drizzle tables merged into the adapter
schema). This is how you add organizations and teams, another OAuth provider, or
any other Better Auth plugin.
You own the migrations for any table a plugin needs. extraSchema only tells
the Drizzle adapter the tables exist; it does not generate or apply their
migrations.
Roles and permissions
Staff sessions are gated by the same resource/action model as API keys. For astaff session caller, requireActor derives the resource from the first
path segment after /v1/admin/ or /v1/public/ and the action from the
HTTP method, then checks it against the session’s scopes. A member with an
explicit, non-wildcard scope set is gated exactly like an API key; full-access
members hold * and pass everything (the default for unassigned members, so
existing deployments are unaffected).
This staff RBAC is enforced by default. The VOYANT_RBAC_ENFORCE environment
variable is a kill switch: set it to 0, false, or off to disable
enforcement without a code change (for example, an emergency rollback).
The role and resource catalog lives in @voyant-travel/auth/permissions.
voyantStatements defines the operator-management resources (operator,
connection, oauthClient, apiKey, operatorGrant, auditLog, settings)
and the owner / admin / member roles are built on Better Auth’s
organization access control over those statements.
For a route that depends on one specific grant rather than a surface-derived
one, use the explicit guard. It checks the caller’s scopes first, then falls
through to auth.hasPermission, which is why hasPermission is one of the
integration callbacks:
API tokens
voy_ bearer tokens are Better Auth API keys. Their authorization model is a
Record<string, string[]> of resource to actions (for example
{ products: ["read"], workflows: ["trigger"] }), with wildcards supported.
The resource is the first path segment after /v1/admin/ or /v1/public/, and
the action is derived from the HTTP method, so GET /v1/public/products is
admitted by { products: ["read"] }.
Helpers permissionsToStrings and hasApiKeyPermission, plus the descriptor
catalog (API_KEY_PERMISSION_GROUPS, API_KEY_PERMISSION_PRESETS), live in
@voyant-travel/types/api-keys. Tokens are managed through the
/auth/api-tokens facade (list, create, update, delete, and /rotate). This is
covered end to end in
Auth and identity → API tokens and scopes;
do not re-derive it here.
Voyant login
When a deployment is provisioned by Voyant, auth runs invoyant-cloud
mode: Voyant acts as an identity broker and owns identity, organization
membership, app scope, and revalidation, while the deployment keeps its local
Better Auth mirror user, session cookie, and local token storage. In that mode,
local sign-up, password reset, OAuth, and invitations are disabled server-side.
Local development and self-host run in local mode with the regular Better Auth
flows described above. Either way, route and module code only ever sees the
normalized auth context. See Voyant auth for
that flow.
Next steps
Auth and identity
The concepts: actor types, identity vs authorization, the token baseline.
Voyant auth
How the identity-broker mode works for Cloud-provisioned deployments.
API routes
Where the auth middleware and guards sit in the route pipeline.
SDK authentication
Authenticate a typed client with sessions or API tokens.