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

# Sign in through Voyant

> Pair a deployment's admin auth with the Voyant identity broker: how the trusted-assertion sign-in flow mints a local mirror session, what stays local versus delegated, how revalidation and revocation work, and how team management proxies to the platform.

A deployment runs its admin surface in exactly one of two mutually exclusive auth
modes, selected by the `VOYANT_ADMIN_AUTH_MODE` environment variable:

* `local` (the default): the deployment owns the whole login experience through
  its own Better Auth flows. This is the right mode for local development and
  self-hosted deployments.
* `voyant-cloud`: the deployment delegates the login experience to Voyant,
  which acts as the identity broker. This is the mode that Voyant
  Cloud-provisioned admin deployments run in.

The two modes are resolved in `resolveOperatorAuthMode` (in the operator
starter's `src/api/auth/handler.ts`). An empty or missing value resolves to
`local`. Any value that is not exactly `"local"` or `"voyant-cloud"` fails closed
to `voyant-cloud`, so a typo never accidentally re-enables the local login flows.

<Note>
  This guide covers the `voyant-cloud` mode end to end. For the actor model, API
  tokens, and the normalized auth context that every route consumes regardless of
  mode, read [Auth and identity](/docs/platform/fundamentals/auth) first.
</Note>

## How it works

In `voyant-cloud` admin mode, Voyant is an **identity broker**, not a full
replacement for the deployment's session layer. The model is "trusted assertion
to local session mirror," not "delegate every request to the platform." The
platform owns the human-facing parts of identity; the deployment keeps owning the
session it actually issues.

Concretely, the platform owns the login UI, organization membership, app scope,
the signed grant, and ongoing revalidation. The deployment still owns a local
Better Auth session cookie, a **local mirror user** row, its JWKS and JWT token
endpoints, sign-out, and its local API keys. After sign-in, route and module code
only ever sees the same normalized `{ userId, actor, scopes }` auth context it
sees in `local` mode. Nothing downstream is aware that Voyant was involved.

This is the admin realm only. Storefront customer accounts run in an isolated
Better Auth realm with separate storage, cookies, and signing secrets. Managed
deployments select `adminAuth: "voyant-cloud"` and
`customerAuth: "better-auth"`; there is no shared auth selector or signing key
between the two realms.

Merchant social credentials never pass through WorkOS or the profile settings
object. Voyant stores them KMS-encrypted per storefront and environment;
internal runtime resolution uses opaque `vault://` references, while dashboard
and public storefront configuration expose only enabled/configured booleans.
Storefront callbacks must use verified HTTPS origins. Customer
sessions need a same-origin storefront BFF/proxy with host-only cookies; that
delivery layer is not implemented by the control-plane credential store.

## Configure storefront customer accounts

The managed Voyant overview includes a **Storefront customer accounts** card.
Choose an experience app and one of its environments, upload write-only Google,
Facebook, or Apple credentials, then enable the desired sign-in methods. Email
code is the default; email/password and social providers are opt-in. Enabling a
social provider fails until that provider has a stored credential bundle.

Credential writes return `204` with no response body. Subsequent reads expose
only `{ configured: boolean }` per provider, never plaintext, ciphertext, or a
credential reference. Replacing a credential uses the same write-only action.

For now the dashboard derives the callback origin from the selected experience
app's single active custom hostname. The API independently checks that origin
against the active hostname, so a browser cannot assert an arbitrary origin as
verified. Apps without an active custom hostname can upload credentials but
cannot save the method policy. Supporting multiple verified storefront domains
requires a first-class multi-hostname verification model; it must not be added
as a free-form origin field.

The organization-scoped API contract is rooted at:

```text theme={null}
/dashboard/v1/organizations/:organization/apps/:operatorApp/storefront-customer-auth/:storefrontApp/environments/:environment
```

`GET` returns the non-secret projection, `PUT` saves methods plus the verified
origin, and `PUT /credentials/:provider` replaces a write-only credential. All
mutations use the normal Cloud `manage` permission check.

This control-plane UI does not turn the operator origin into a customer-session
host. A storefront still needs the same-origin BFF/proxy that resolves its scoped
credential references and mounts the Better Auth customer realm on the
storefront origin.

Here is what each concern resolves to in `voyant-cloud` mode:

| Concern                                            | Where it lives                                           |
| -------------------------------------------------- | -------------------------------------------------------- |
| Login UI and credentials                           | Delegated to Voyant                                      |
| Sign-up, password reset, OAuth, email verification | Blocked locally (handled, if at all, by the platform)    |
| Invitation redemption                              | Delegated to the platform team API                       |
| Session cookie and `get-session`                   | Local Better Auth                                        |
| JWT token and `jwks` endpoints                     | Local Better Auth                                        |
| Sign-out                                           | Local Better Auth                                        |
| The mirror user row                                | Local (a deployment-issued user)                         |
| Membership and team roster                         | Delegated to the platform via the `/v1/admin/team` proxy |
| Member scopes (RBAC)                               | Carried in the signed assertion, mirrored locally        |
| Ongoing access                                     | Revalidated against the platform on an interval          |

### The sign-in flow

The browser sign-in flow is a code-and-exchange round trip between the
deployment and Voyant. The deployment issues a signed state cookie, hands
off to the platform for authentication, then exchanges a one-time `code` for a
signed assertion it verifies locally before minting its own session.

```mermaid theme={null}
sequenceDiagram
  participant B as Browser
  participant D as Deployment
  participant C as Voyant

  B->>D: GET /auth/admin/services/start
  D->>D: Sign state cookie (HMAC via SESSION_CLAIMS_ADMIN_SECRET)
  D-->>B: 302 to Cloud start URL (deployment_id, redirect_uri, state, nonce, surface=admin)
  B->>C: Authenticate on Voyant
  C-->>B: 302 to /auth/admin/services/callback?code&state
  B->>D: GET /auth/admin/services/callback?code&state
  D->>D: Verify state cookie matches state param
  D->>C: POST exchange URL with code (Bearer client token)
  C-->>D: Signed assertion (compact JWS)
  D->>C: GET JWKS URL
  C-->>D: Public keys
  D->>D: Verify signature and claims (iss, aud, deploymentId, nonce, exp)
  D->>D: Upsert local mirror user plus link row (broker ids, deployment, role, scopes)
  D->>D: Create local Better Auth session, set session cookie
  D-->>B: 302 to next
```

Step by step, verified against `packages/auth/src/cloud-broker/state.ts`,
`packages/auth/src/cloud-broker/assertion.ts`,
`packages/auth/src/cloud-admin-session.ts`, and the operator application's
`src/api/auth/handler.ts`:

1. `GET /auth/admin/services/start` issues a signed state cookie (an HMAC-SHA256 signature
   over the state payload, keyed by `SESSION_CLAIMS_ADMIN_SECRET`) and returns a `302`
   to `VOYANT_CLOUD_ADMIN_AUTH_START_URL` carrying `deployment_id`,
   `redirect_uri`, `state`, `nonce`, and `surface=admin` (plus `next`, and
   `app_id` and `environment` when configured).
2. The user authenticates on Voyant.
3. `GET /auth/admin/services/callback?code&state` runs the Better Auth plugin
   `createVoyantCloudAdminAuthPlugin`. It verifies the state cookie against the
   `state` query parameter and its expiry, then exchanges the `code` at
   `VOYANT_CLOUD_ADMIN_AUTH_EXCHANGE_URL` (with the deployment client token as a
   Bearer credential) for a signed assertion in compact JWS form.
4. The deployment verifies the assertion's `RS256` signature against
   `VOYANT_CLOUD_ADMIN_AUTH_JWKS_URL` and validates its claims:
   `iss` is `https://api.voyant.travel`, `aud` matches the configured audience,
   `deploymentId` matches this deployment, `nonce` matches the state, and `exp`
   is in the future.
5. It upserts a **local mirror user** (with a local UUID, not the broker user id)
   plus a link row that stores the broker ids, deployment id, role, and member
   scopes. It then creates a **local** Better Auth session, sets the session
   cookie, and redirects to the validated `next` path.

From this point on, the deployment authenticates the user with its own session
cookie. The assertion was a one-time proof of identity, not a long-lived token.

## Configure cloud mode

Cloud mode needs the broker endpoints, the deployment's identity, and the cookie
signing secret. The operator application declares these in `env.d.ts`:

| Variable                                                                   | Purpose                                                            |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `VOYANT_ADMIN_AUTH_MODE`                                                   | Set to `voyant-cloud` to enable broker mode                        |
| `VOYANT_CLOUD_ADMIN_AUTH_START_URL`                                        | Where `/auth/admin/services/start` redirects the browser           |
| `VOYANT_CLOUD_ADMIN_AUTH_EXCHANGE_URL`                                     | Where the callback exchanges the `code` for an assertion           |
| `VOYANT_CLOUD_ADMIN_AUTH_JWKS_URL`                                         | Public keys used to verify the assertion signature                 |
| `VOYANT_CLOUD_ADMIN_AUTH_REVALIDATE_URL`                                   | Endpoint that confirms ongoing access                              |
| `VOYANT_CLOUD_ADMIN_AUTH_AUDIENCE`                                         | Expected assertion `aud` (defaults to the deployment id)           |
| `VOYANT_CLOUD_ADMIN_AUTH_CLIENT_TOKEN`                                     | Bearer credential the deployment presents to the platform          |
| `VOYANT_CLOUD_DEPLOYMENT_ID`                                               | This deployment's id, echoed in the start URL and assertion checks |
| `VOYANT_CLOUD_APP_ID`, `VOYANT_CLOUD_APP_SLUG`, `VOYANT_CLOUD_ENVIRONMENT` | App and environment identity                                       |
| `SESSION_CLAIMS_ADMIN_SECRET`                                              | Admin-realm HMAC key for the state cookie (at least 32 characters) |
| `BETTER_AUTH_ADMIN_SECRET`                                                 | Better Auth signing key for the local admin session mirror         |

The storefront customer realm uses its own `SESSION_CLAIMS_CUSTOMER_SECRET`
and `BETTER_AUTH_CUSTOMER_SECRET`; neither is accepted by the admin realm.

<Note>
  Voyant-provisioned deployments receive these values from the platform
  automatically. A self-hosted deployment needs none of them: it stays in `local`
  mode and uses the regular Better Auth flows.
</Note>

If `VOYANT_ADMIN_AUTH_MODE` is `voyant-cloud` but the exchange or revalidate
configuration is incomplete, the cloud routes respond with a clear "not
configured" status rather than silently falling back to local login, since the
mode itself fails closed.

## Revalidation and revocation

A local session cookie outlives a single platform decision, so cloud mode
revalidates access against the platform on an interval rather than trusting the
cookie indefinitely.

On each protected request, the deployment runs a revalidation step keyed on the
session. If the session was revalidated within the cached interval, there is no
network call. Otherwise the deployment makes a `POST` to
`VOYANT_CLOUD_ADMIN_AUTH_REVALIDATE_URL`. When the platform reports active, the
deployment refreshes the cached interval and, if the platform sent an updated
scope set, the member's cached RBAC scopes. That is why a permission change made
on the platform takes effect within one interval rather than only at next login.
When the platform reports revoked, the request is rejected and the session is
torn down. The default interval is roughly 15 minutes
(`DEFAULT_CLOUD_SESSION_REVALIDATE_AFTER_SECONDS` in
`packages/auth/src/cloud-admin-session.ts`).

Personal `voy_` API tokens are revalidated the same way in cloud mode. The token
validation hook re-checks the owning user's platform membership, so a token stops
authenticating once that user's Voyant access is revoked, even though the
token itself is still a local Better Auth API key.

<Warning>
  Revocation latency is bounded by the revalidation interval, not instant. A user
  whose platform access is revoked can keep using a cached session or token until
  the next revalidation, up to roughly 15 minutes later.
</Warning>

## Team and invitations

Membership lives on the platform in cloud mode, so the deployment does not manage
its own roster. The operator application mounts a cloud-only team surface at
`/v1/admin/team` that proxies to the platform member API. It exposes listing
members, listing roles, listing and creating and revoking invitations, setting a
member's deployment access, and setting a member's per-deployment permissions
(the `resource:action` scope set). These routes return `404` in `local` mode.

Every proxied call carries the deployment client token, the deployment id, and
the acting staff user's broker id (resolved from the local link row). The
**platform** re-verifies that the acting user is an organization manager before
mutating anything, so the deployment never self-asserts authority over the
roster.

In `local` mode the equivalent capability is the deployment's own
`/v1/admin/invitations` super-admin flow, which issues and redeems invitations
against the local user table. The two are mutually exclusive: the local
invitations surface is the local-mode path, and the team proxy is the cloud-mode
path.

## What changes in cloud mode

Switching a deployment into `voyant-cloud` mode reshapes the local auth surface:

* **The Better Auth surface is reduced to an allowlist.** Only
  `/auth/admin/get-session`, `/auth/admin/jwks`, `/auth/admin/session`,
  `/auth/admin/sign-out`, and `/auth/admin/token` pass through to Better Auth,
  alongside the cloud-only `/auth/admin/services/start` and
  `/auth/admin/services/callback` routes. Managed operator deployments expose the
  same routes under `/api/auth/admin/*`. Everything else returns a "local auth
  disabled" response.
* **Local self-service flows are blocked.** Sign-up, password reset, OAuth, email
  verification, and local invitation redemption do not run in the deployment.
  Those concerns belong to the platform.
* **The mirror-user invariant holds.** The local `user.id` is a freshly minted
  UUID, deliberately not the broker user id. The broker ids are kept on a
  separate link row. If you join other records on a user id, join on the local
  `user.id`, never on the broker id.

Because module code only ever consumes the normalized auth context, none of this
changes how you author routes. A route that calls `requireUserId(c)` behaves
identically in both modes; only the machinery that produced the session differs.

## Next steps

<CardGroup cols={2}>
  <Card title="Auth and identity" icon="fingerprint" href="/docs/platform/fundamentals/auth">
    Actor types, the normalized auth context, API tokens, and scopes.
  </Card>

  <Card title="Extend auth" icon="shield-halved" href="/docs/guides/extending-auth">
    Wire a different provider or add behavior behind the auth seam.
  </Card>

  <Card title="Voyant" icon="cloud" href="/docs/services/overview">
    What the platform provisions and operates for a deployment.
  </Card>

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