Skip to main content
Authentication in Voyant is shared runtime infrastructure. The platform resolves request identity once, normalizes it to { 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 iam schema (user, session, account, verification, apikey tables).
  • 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: 600 seconds).
  • Session cookie cache on by default with a 5-minute TTL: session data rides in a short-lived signed cookie so getSession skips Postgres on most requests. The trade-off is that a revoked session can stay usable for up to the TTL.
  • secret read from BETTER_AUTH_ADMIN_SECRET by default, or from BETTER_AUTH_CUSTOMER_SECRET when realm: "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 both GOOGLE_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 a secondaryStorage (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 a databaseHooks.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 forwards options.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 a staff 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 in voyant-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.