> ## Documentation Index
> Fetch the complete documentation index at: https://voyant.travel/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Use and extend auth

> The practical companion to Auth and identity: wire a custom provider through the resolve seam, configure the Better Auth instance, add a Better Auth plugin, gate staff sessions with role scopes, and issue API tokens.

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](/docs/platform/fundamentals/auth) 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:

| Seam                                             | What it controls                                                                | Where it lives                                   |
| ------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------ |
| The provider integration (`auth` on `createApp`) | How a request becomes a normalized auth context, regardless of credential       | `VoyantAuthIntegration` in `@voyant-travel/hono` |
| The Better Auth instance (`createBetterAuth`)    | The default session/email/OAuth/API-key provider that integration usually wraps | `@voyant-travel/auth/server`                     |

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:

| Callback         | Signature                                    | Purpose                                                                                                                     |
| ---------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `handler`        | `(env) => { fetch }`                         | Mounts the auth provider's own routes under `/auth/*` (sign-in, callback, OTP).                                             |
| `resolve`        | `(args) => VoyantRequestAuthContext \| null` | Turns any credential (cookie, custom token, third-party IdP) into the normalized context. Returning `null` means anonymous. |
| `hasPermission`  | `(args) => boolean`                          | Backs the explicit `requirePermission(...)` guard.                                                                          |
| `validateApiKey` | `(args) => boolean`                          | An extra gate applied to validated `voy_` API keys before they are admitted.                                                |

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.

```ts theme={null}
import { createApp } from "@voyant-travel/hono"
import type { VoyantRequestAuthContext } from "@voyant-travel/hono/types"

export const app = createApp({
  db: dbFromEnv,
  auth: {
    // Swap in any identity source here. Read whatever credential you issued
    // (a cookie, a provider token) off the request and normalize it.
    async resolve({ request, env }): Promise<VoyantRequestAuthContext | null> {
      const session = await verifyMyProviderSession(request, env)
      if (!session) return null

      return {
        userId: session.userId,
        // Required. For a single-tenant admin app where every session is
        // operator staff, return "staff". A public/customer session must
        // return "customer" (or "partner"/"supplier") so /v1/public/* guards
        // admit it.
        actor: session.isStaff ? "staff" : "customer",
        sessionId: session.id,
        email: session.email,
        // Optional: a staff session's scopes feed member RBAC (see below).
        scopes: session.scopes,
      }
    },
  },
})
```

`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.

```ts theme={null}
import { createBetterAuth } from "@voyant-travel/auth/server"

export const auth = createBetterAuth({
  db,
  // Wire real email delivery; without these the factory logs OTP/reset
  // links to the console.
  async sendVerificationOTP({ email, otp }) {
    await sendEmail(email, `Your code is ${otp}`)
  },
  async sendResetPassword({ user, url }) {
    await sendEmail(user.email, `Reset your password: ${url}`)
  },
  // Revocation-sensitive deployment? Turn off the cookie cache so every
  // request re-checks the session row.
  sessionCookieCache: false,
})
```

### 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.

<Note>
  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.
</Note>

### 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.

```ts theme={null}
import { createBetterAuth } from "@voyant-travel/auth/server"
import { organization } from "better-auth/plugins"
import { orgTables } from "./schema/org.js" // your Drizzle tables + your migration

export const auth = createBetterAuth({
  db,
  plugins: [organization()],
  // The adapter now knows these tables. Generate and apply their migration
  // yourself. The framework does not.
  extraSchema: orgTables,
})
```

## 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:

```ts theme={null}
import { requirePermission } from "@voyant-travel/hono"

adminRoutes.use(
  "/settings/*",
  requirePermission(dbSource, "settings", "update", { auth }),
)
```

## 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](/docs/platform/fundamentals/auth#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](/docs/guides/voyant-auth) for
that flow.

## Next steps

<CardGroup cols={2}>
  <Card title="Auth and identity" icon="shield-halved" href="/docs/platform/fundamentals/auth">
    The concepts: actor types, identity vs authorization, the token baseline.
  </Card>

  <Card title="Voyant auth" icon="cloud" href="/docs/guides/voyant-auth">
    How the identity-broker mode works for Cloud-provisioned deployments.
  </Card>

  <Card title="API routes" icon="globe" href="/docs/guides/consume-the-api">
    Where the auth middleware and guards sit in the route pipeline.
  </Card>

  <Card title="SDK authentication" icon="key" href="/docs/sdks/authentication">
    Authenticate a typed client with sessions or API tokens.
  </Card>
</CardGroup>
