Skip to main content
Authentication in Voyant is shared runtime infrastructure, not a pattern each module reinvents. The framework resolves request identity once, normalizes it, and hands routes and services a consistent auth context. Modules consume that context; they do not own their own auth semantics. This page explains the actor types, how authentication is wired, the difference between identity and authorization, API tokens and scopes, service-to-service auth, and the token signing baseline.

Identity is not authorization

Voyant keeps three concerns separate, and conflating them is the most common auth mistake.
ConcernQuestion it answers
IdentityWho is the user?
Actor contextWhich workspace or organization do they act in, and as what kind of actor?
PermissionWhat are they allowed to do here?
These are related but distinct. A route should ask for the narrowest thing it actually needs:
  • requireUserId(...) for “must be signed in.”
  • Actor-aware middleware when the route genuinely depends on workspace or actor context.
  • An explicit permission check when the route depends on a specific grant.
Do not treat every authenticated route as a permission-checked route, and do not collapse identity, actor selection, and permission checks into one generic guard.

Actor types

Voyant request context carries an actor type: the business class of the principal making the request. The shared require-actor middleware enforces one of:
  • staff: operator and back-office users on the admin surface.
  • customer: end travelers on the public surface.
  • partner: external partners with their own scoped access.
  • supplier: upstream suppliers feeding inventory or fulfilment.
Actor type is the business audience. It is separate from the caller type, which records the mechanism that authenticated the request (a session, an API key, an internal request). Both are normalized onto the request context so routes and the action ledger can record who acted and how.

How authentication is wired

Session-based auth is the primary default for admin and authenticated public surfaces. In the first-party starters that means Better Auth: it owns session issuance, the session cookie, the user table, and the JWT/JWKS endpoints. The core framework packages stay provider-agnostic. They expect request auth to be resolved already and consume a normalized { userId, actor, scopes } context, so a deployment can wire a different provider without rewriting module code. The Hono middleware chain runs auth handler → requireAuth → ... → actor guards before any module route. @voyant-travel/hono exposes the pieces:
  • ./middleware/auth resolves session, API-key, and shared-secret JWT auth.
  • ./middleware/require-actor enforces the staff / customer / partner / supplier actor.
  • ./middleware/require-permission applies permission-based guards.
Routes consume the result through helpers rather than reading raw request state:
import { requireUserId, requireActor } from "@voyant-travel/hono"

adminRoutes.get("/me/preferences", async (c) => {
  const userId = requireUserId(c)
  const prefs = await c.var.container.identityService.getPreferences(userId)
  return c.json({ preferences: prefs })
})
Voyant Cloud-provisioned admin deployments run in voyant-cloud auth mode, where Voyant Cloud is an identity broker: it owns WorkOS identity, organization membership, app scope, broker grants, and revalidation, while the tenant deployment still owns 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. Either way, route and module code only ever sees the normalized auth context.

Identity storage

User identity and profile data is a shared capability that both admin and public surfaces can read and update safely: locale, timezone, contact and billing preferences, and traveler identity data where appropriate. One subtlety worth keeping straight: admin UI locale is a user preference, not the same concern as product-content translation. The identity surface stores user-level locale and timezone so the runtime can resolve presentation; it does not own content translation.

API tokens and scopes

API tokens are for automation and cross-runtime integrations: CMS sync jobs, storefront proxies, webhook relays, workflow triggers. They are not operator sessions, and they must never be promoted into one. Under the hood, an API token is a Better Auth API key. Better Auth owns creation, hashing, storage, listing, rotation, and deletion. The authorization model is Better Auth’s Record<string, string[]> permission shape:
{
  products: ["read"],
  bookings: ["read", "write"],
  workflows: ["trigger"],
  webhooks: ["relay"],
}
Wildcards are supported ({ "*": ["*"] }, { products: ["*"] }, { "*": ["read"] }). The catalog is intentionally extensible: a new module uses its own name as the resource without a central enum change, though common permissions are added to the shared descriptor catalog (@voyant-travel/types/api-keys) so the management UI can display them.

How the route resource maps to a permission

For API-key callers, requireActor(...) derives the required permission from the URL and HTTP method. It takes the first path segment after /v1/admin/ or /v1/public/ as the resource and the method as the action:
MethodDerived action
GET / HEADread or search
POSTwrite, trigger, or relay
PUT / PATCHwrite
DELETEdelete
So GET /v1/public/products is admitted by { products: ["read"] } (or a matching wildcard), and POST /v1/admin/workflows/events is admitted by { workflows: ["trigger"] }. This is why route URLs keep their resource segment aligned with the permission you expect third-party callers to hold. When a voy_ token authenticates, the middleware sets callerType: "api_key", the token id, and the resolved scopes on the context. Audit consumers record the token id, never the raw bearer secret.
curl https://api.example.com/v1/public/products \
  -H "Authorization: Bearer voy_..."
Tokens are created from the operator Settings screen or the /auth/api-tokens management facade, the returned secret is shown once, and a token can be rotated in place (new secret, same id, permissions, and counters) so the previous secret stops authenticating immediately.
In Cloud mode, a personal API token is still owned by a Cloud-mirrored user. Its use is gated on current Voyant Cloud membership: if the user’s WorkOS or Cloud membership is revoked, that user’s local API keys are disabled. Local tokens remain Better Auth API keys, but Cloud mode revalidates membership before honoring them.
See Service API keys and API routes for how this plays out at the route layer.

Machine-to-machine and internal auth

Machine and internal auth is kept distinct from user-session auth. Voyant does not overload the session model to cover every non-user access pattern. There are a few separate primitives, and they are not interchangeable:
  • API keys (voy_ bearer tokens), for external automation and cross-runtime integrations, validated against Better Auth permissions as above.
  • Internal requests, marked on the context (isInternalRequest) and admitted past the actor guard for trusted in-deployment callers.
  • Shared-secret bearer claims through @voyant-travel/utils/session-claims, for short-lived internal bearer tokens and runtime-local verification without a database round trip. This produces a minimal normalized { userId, sessionId } claim set.
Keeping these separate is deliberate. Cookie sessions, API keys, shared-secret bearer claims, and any future machine token are not blurred into one generic “token auth” concept.

Token signing and key distribution

The current baseline is intentionally narrow, and the docs are explicit about not pretending Voyant is already a JWKS-distributed token platform.
  • Better Auth session cookies remain the primary user-session model. Voyant does not replace them with a JWT-first architecture.
  • session-claims is a narrow symmetric helper: HMAC-SHA256, short-lived, minimal payload, verified locally through Web Crypto. It is a runtime convenience, not a platform-wide token standard.
  • Edge-sensitive code may verify identity differently (for example a direct session-table check) when runtime constraints differ, as long as the resulting auth context is normalized the same way.
Asymmetric signing and JWKS distribution are deferred until there is a real independent verifier surface: multiple services that must verify Voyant-issued tokens without a shared secret, or a non-Voyant consumer that needs public-key verification. Key-rotation pressure alone does not justify the jump; the verifier topology has to require it. When that day comes, the first slice stays narrow: one signing-key abstraction, one rotation policy, one verifier audience.
Route and module code should consume only the normalized auth context. It should never need to know whether the upstream credential was a session cookie, a voy_ API key, or a shared-secret bearer token. Keep token-format details inside the shared auth and runtime surfaces.

Next steps

API routes

How the auth middleware and guards sit in the route pipeline.

SDK authentication

Authenticate a typed client with sessions or API tokens.

Events

The action ledger that records who acted, under which authority.

Architecture

The deployment boundary that backs Voyant’s tenancy model.