External identity providers

WorkOS, Kinde, Clerk, Auth0, Supabase Auth, and generic OIDC as Voltro auth strategies — JWT/JWKS verification via the shared jwtBearerStrategy, claims-to-tenant mapping, and how they stack with password auth.

Voltro ships first-party strategy plugins for six identity providers, including a generic OIDC adapter that covers anything publishing an OpenID Connect discovery document:

Package Provider Strategy id
@voltro/plugin-auth-workos WorkOS AuthKit / SSO workos
@voltro/plugin-auth-kinde Kinde kinde
@voltro/plugin-auth-clerk Clerk clerk
@voltro/plugin-auth-auth0 Auth0 auth0
@voltro/plugin-auth-supabase Supabase Auth (GoTrue) supabase
@voltro/plugin-auth-oidc Generic OIDC (Okta, Keycloak, Cognito, Azure AD, Google Workspace, …) configurable via id:

Every plugin is a thin wrapper — ~70–110 lines each — over one shared engine: jwtBearerStrategy. They add nothing but provider-specific defaults (JWKS URL, cookie name, tenant-claim mapping). If your IdP isn't in the list AND doesn't expose a standard OIDC discovery document, point jwtBearerStrategy at its JWKS endpoint directly.

The model: verify, don't redirect

These strategies do not run the OAuth redirect dance on the server. The provider's own SDK runs that in the browser and lands a signed JWT — either as a cookie (cookie mode) or an Authorization: Bearer header. The Voltro strategy's job is narrow and stateless:

request ─► extract token (Bearer or cookie)
        ─► verify signature against the provider's JWKS  (cached)
        ─► map verified claims → Subject (+ tenantId)
        ─► attach raw claims under metadata.claims

This means you keep your Subject model and multi-tenant scope — the IdP authenticates, but tenantId and the typed Subject stay the framework's, not the vendor's. No session is stored server-side for these strategies; the JWT is the session, re-verified per request (JWKS is cached, so steady-state verification is CPU-local).

Security: the JWKS verifier allows asymmetric algorithms only (RS256 / ES256, optionally PS256 / EdDSA). HS256 is intentionally rejected on the JWKS path — a shared-secret HMAC over a public JWKS flow is a downgrade vector. (Legacy / self-hosted Supabase that signs symmetrically is the one exception: it verifies through a separate, explicit shared-secret path — supabaseStrategy({ jwtSecret }) — never the JWKS verifier. See Supabase Auth.)

There are two ways to put an external IdP in front of a Voltro app; this page's per-provider sections cover the first. In the verify model above, the IdP's JWT is the session — every request re-verifies it and no framework session is minted. The second model uses the IdP only for the login step — redirect to its hosted UI, take the callback, then mint your own voltro:session — so the IdP is an alternate front door, not the session authority. See WorkOS SSO login below for a full example.

Client: getting the token to the api (HTTP and WebSocket)

The strategy above only verifies — your browser still has to send the token on every request, over both transports the framework uses:

  • HTTP (POST /rpc, loaders, /auth/*): the provider SDK's fetch (or your own) sends Authorization: Bearer <token> normally — nothing framework-specific.

  • WebSocket (live subscriptions): a browser cannot set headers on a WS upgrade, so the token can't ride the handshake. Pass it to mount() instead — the framework attaches it to every rpc message frame (not the upgrade), where the same strategy reads it per call:

    // .framework/main.tsx (or your mount entry)
    mount(App, {
      apis: {
        api: {
          // A thunk is resolved fresh on every (re)connect, so a rotating
          // access token is pulled anew per connect, not frozen at first mount:
          headers: async () => ({
            authorization: `Bearer ${(await supabase.auth.getSession()).data.session?.access_token ?? ''}`,
          }),
        },
      },
    })

    This is required when the api is a separate origin (the common case — api.example.com vs your web origin): there is no shared cookie and no upgrade header, so without headers every subscription connects anonymous (the shell renders, but user-/tenant-scoped data stays empty). A static object works for non-rotating tokens; never hardcode a secret literal — it ships to the browser.

Declaratively — apis.<name>.authHeaders in app.config.ts

Rather than hand-writing a mount() entry just to inject the thunk, declare it on the api in app.config.ts. The framework owns the client mount, the SSR-null case (the resolver runs browser-only — it never fires on the server), and the re-resolve on every reconnect; you supply only the token function:

// app.config.ts
export default {
  type: 'web' as const,
  apis: {
    api: {
      package: '@app/api',
      // Resolved fresh on every (re)connect — a rotating token is pulled anew:
      authHeaders: async () => ({
        authorization: `Bearer ${(await supabase.auth.getSession()).data.session?.access_token ?? ''}`,
      }),
    },
  },
}

authHeaders is a function, so it is imported from app.config.ts into the client bundle rather than serialized — the file must stay browser-safe (no node:* / server-only value imports; the env schema and other pure config are fine). It supersedes a static headers on the same api. This is the preferred form; reach for a hand-written mount() only when you need to wrap the tree in your own provider as well.

WorkOS

import { workosStrategy } from '@voltro/plugin-auth-workos'

workosStrategy({
  clientId: process.env.WORKOS_CLIENT_ID!,   // client_01H… — builds the JWKS URL + audience
  // jwksUrl?:   https://api.workos.com/sso/jwks/<clientId>   (default)
  // issuer?:    https://api.workos.com                       (default)
  // cookieName?: 'wos-session'  (AuthKit cookie mode; null = header-only)
  // defaultTenantId?: fallback when claims expose no org_id
})

Tenant maps from claims.org_id. Verified claims arrive as subject.metadata.claims typed loosely as WorkosClaims (sub, email, org_id, role, permissions, …).

WorkOS SSO login

The strategy above is the verify model — WorkOS' JWT is the session. @voltro/plugin-auth-workos also exports two primitives for the other model, where WorkOS runs the login and your app mints its own session — an alternate front door alongside password sign-in, not a replacement session authority. This is how Voltro Cloud offers a "Sign in with WorkOS SSO" button next to email/password.

  • workosAuthorizationUrl({ clientId, redirectUri, state? }) — builds the WorkOS hosted-login (AuthKit) URL to redirect the browser to. Pass a random state for CSRF defence (and to carry a post-login return path).
  • workosAuthenticateWithCode({ clientId, apiKey, code }) — exchanges the ?code= from the callback for the authenticated WorkosProfile (workosUserId, email, firstName, lastName, organizationId). Server-only — it carries the WorkOS API key (the OAuth client secret).

Both are transport-thin (raw fetch, no @workos-inc/node dependency), so they drop into any app's own routing + provisioning.

The flow

GET /auth/workos/login
   ─► set a random `state` cookie (CSRF defence)
   ─► 302 → workosAuthorizationUrl({ clientId, redirectUri, state })

GET /auth/workos/callback?code=…&state=…
   ─► verify `state` matches the cookie   (else 400 — possible CSRF)
   ─► workosAuthenticateWithCode({ clientId, apiKey, code }) → WorkosProfile
   ─► find-or-provision the local user + org   (SHARED with password signup)
   ─► issueSession(subject) → Set-Cookie: voltro:session=…
   ─► 302 → dashboard

The callback mints the same voltro:session as password sign-in, so everything downstream — the session strategy, org switching, audit — behaves identically. WorkOS handles authentication; your app keeps authorization. The IdP never becomes the session authority; it is simply another way in.

Wiring the routes

Wrap the two routes in a small server-only plugin and add it to plugins in your api's app.config:

import { randomUUID } from 'node:crypto'
import { definePlugin } from '@voltro/protocol'
import { workosAuthorizationUrl, workosAuthenticateWithCode } from '@voltro/plugin-auth-workos'
import { issueSession, resolveSessionSecret } from '@voltro/plugin-auth/session'

export const workosAuthPlugin = () =>
  definePlugin({
    name: 'workos-login',
    httpRoutes: [
      {
        method: 'GET',
        path: '/auth/workos/login',
        handle: async () => {
          const clientId = process.env.WORKOS_CLIENT_ID
          const redirectUri = process.env.WORKOS_REDIRECT_URI
          if (!clientId || !redirectUri) {
            return { status: 503, contentType: 'text/plain', body: 'WorkOS SSO is not configured.' }
          }
          const state = randomUUID()
          const url = workosAuthorizationUrl({ clientId, redirectUri, state })
          return {
            status: 302,
            headers: { location: url, 'set-cookie': `workos-oauth-state=${state}; Path=/; Max-Age=600; SameSite=Lax` },
            body: '',
          }
        },
      },
      {
        method: 'GET',
        path: '/auth/workos/callback',
        handle: async (req) => {
          const clientId = process.env.WORKOS_CLIENT_ID
          const apiKey = process.env.WORKOS_API_KEY
          if (!clientId || !apiKey) {
            return { status: 503, contentType: 'text/plain', body: 'WorkOS SSO is not configured.' }
          }
          const code = new URLSearchParams(req.query).get('code')
          if (!code) return { status: 400, contentType: 'text/plain', body: 'Missing authorization code.' }
          // …also validate `state` against the state cookie (CSRF) before exchanging.
          const profile = await workosAuthenticateWithCode({ clientId, apiKey, code })
          // find-or-provision runs the SAME path as password signup:
          const { userId, orgId } = await findOrProvisionUser(profile)
          const issued = issueSession({ type: 'user', id: userId, tenantId: orgId }, resolveSessionSecret())
          return {
            status: 302,
            headers: {
              location: '/dashboard',
              'set-cookie': `voltro:session=${issued.value}; Path=/; Max-Age=604800; SameSite=Lax`,
            },
            body: '',
          }
        },
      },
    ],
  })

The first SSO login provisions the local user + org (the same routine password signup uses); later logins find the existing user by email. That's why the routes live in your app, not in the plugin — the provisioning and session model are yours; the plugin only supplies the reusable OAuth primitives. Match the voltro:session cookie's attributes (e.g. Secure, HttpOnly) to your app's existing session cookie.

Configuration

All three variables are optional — the routes return 503 until they are set, so the app boots without WorkOS and you enable SSO by supplying credentials:

Env var Secret Purpose
WORKOS_CLIENT_ID no WorkOS Client ID (client_…) for the hosted login.
WORKOS_API_KEY yes WorkOS API key (sk_…) — the OAuth client secret used in the code exchange.
WORKOS_REDIRECT_URI no The callback URL, registered in the WorkOS dashboard — e.g. https://app.voltro.cloud/auth/workos/callback.

Add a "Sign in with WorkOS SSO" link on your sign-in page pointing at GET /auth/workos/login, and the round trip runs end to end.

Kinde

import { kindeStrategy } from '@voltro/plugin-auth-kinde'

kindeStrategy({
  issuer: 'https://yourcompany.kinde.com',   // required
  // jwksUrl?:   <issuer>/.well-known/jwks    (default)
  // cookieName?: 'kinde_access_token'        (null = header-only)
  // defaultTenantId?: for single-tenant setups
})

Tenant maps from claims.org_code, falling back to claims.org_codes[0].

Clerk

import { clerkStrategy } from '@voltro/plugin-auth-clerk'

clerkStrategy({
  frontendApi: 'https://clerk.yourapp.com',  // Clerk Frontend API host
  // jwksUrl?:   <frontendApi>/.well-known/jwks.json  (default)
  // issuer?:    <frontendApi>                          (default)
  // cookieName?: '__session'  (Clerk's hardcoded cookie name)
  // defaultTenantId?: when no org_id claim is present
})

Tenant maps from claims.org_id (Clerk's "Organizations" feature). Clerk's default __session tokens carry no aud claim, so the audience check is off unless you configure one.

Auth0

import { auth0Strategy } from '@voltro/plugin-auth-auth0'

auth0Strategy({
  domain:   'acme.us.auth0.com',                    // required — builds JWKS URL + issuer
  audience: 'https://api.acme.com',                 // your API identifier
  // jwksUrl?:     https://<domain>/.well-known/jwks.json  (default)
  // issuer?:      https://<domain>/                       (default — TRAILING SLASH MATTERS)
  // tenantClaim?: 'https://voltro.dev/tenant'            (default namespaced URN)
  // cookieName?:  null                                    (header-only by default)
  // defaultTenantId?: fallback for single-tenant Auth0 setups
})

Tenant maps from a namespaced custom claim (Auth0's rules require non-standard claims to live under a URN). Use a custom Auth0 Action to copy your app metadata into the namespaced claim, or configure tenantClaim to an unnamespaced field that already exists in your token.

Supabase Auth

import { supabaseStrategy } from '@voltro/plugin-auth-supabase'

supabaseStrategy({
  projectRef: 'abcdef',                              // builds the hosted JWKS URL
  // OR self-hosted GoTrue:
  // jwksUrl: 'https://my-supabase.example.com/auth/v1/.well-known/jwks.json',
  // issuer:  'https://my-supabase.example.com/auth/v1',

  // tenantClaim?: 'app_metadata.tenant_id'           (default, dotted path)
  // audience?:    'authenticated'                    (default — Supabase's role)
  // defaultTenantId?: for single-tenant deployments
})

Tenant maps from app_metadata.tenant_id by default (a server-controlled blob; users can't forge it). Apps that query tenants via user_metadata set tenantClaim: 'user_metadata.org' — same syntax, dotted-path resolution. cookieName defaults to null (header-only); Supabase's JS SDK stores the access token at sb-<projectRef>-auth-token as a JSON payload, so most Voltro apps just consume the Bearer header and leave the cookie path off.

Generic OIDC (Okta, Keycloak, Cognito, Azure AD, …)

For any IdP that publishes an OpenID Connect discovery document, @voltro/plugin-auth-oidc discovers the JWKS at boot — no per-provider package needed:

import { oidcStrategy } from '@voltro/plugin-auth-oidc'

oidcStrategy({
  id:       'okta',                                  // stable id for logs + metadata.provider
  issuer:   'https://acme.okta.com',
  audience: 'api://acme',
  tenantClaim: 'acme/tenant_id',                     // your provider's tenant claim
})

The strategy fetches ${issuer}/.well-known/openid-configuration on the first request, extracts jwks_uri, and caches the discovery document for the lifetime of the process. Pass jwksUrl explicitly to skip the discovery roundtrip entirely (slightly faster cold-start, no behavioural difference). Pass discoveryUrl if your provider hosts the document at a non-standard path.

Common config combinations:

Provider issuer tenantClaim Notes
Okta https://<org>.okta.com/oauth2/default acme/tenant_id (custom claim) Use the "default" authorization server unless you've set up a custom one.
Keycloak https://<host>/realms/<realm> realm_access.tenant Add a custom claim mapper to surface the tenant.
Cognito https://cognito-idp.<region>.amazonaws.com/<userPoolId> custom:tenant_id Cognito prefixes custom claims with custom:.
Azure AD https://login.microsoftonline.com/<tenantId>/v2.0 tid tid IS the Azure tenant — usually you map it 1:1 to Voltro's tenantId.
Google Workspace https://accounts.google.com hd hd is the hosted-domain claim.

Stacking with password auth

Strategies compose — you can accept both your own password sessions and an external IdP during a migration, or per surface:

import { composeAuthStrategies } from '@voltro/protocol'
import { voltroPasswordStrategy } from '@voltro/plugin-auth'
import { clerkStrategy } from '@voltro/plugin-auth-clerk'

const resolve = composeAuthStrategies([
  voltroPasswordStrategy(),                            // existing users on our cookie
  clerkStrategy({ frontendApi: process.env.CLERK_FRONTEND_API! }),  // new users on Clerk
])

First match wins, so existing password sessions keep working while new sign-ups flow through Clerk — no big-bang cutover. See strategies for chain semantics (and why a failed verdict stops the chain rather than falling through).

Reading provider claims in handlers

The verified JWT claims ride along on the Subject so handlers can use provider-specific data without the framework knowing the provider's shape:

const execute = async (input, ctx) => {
  const s = ctx.subject
  if (s.metadata?.provider === 'workos') {
    const claims = s.metadata.claims as WorkosClaims
    if (!claims.permissions?.includes('billing:write')) {
      throw new Unauthenticated({ reason: 'missing billing:write' })
    }
  }
  // …
}

metadata.provider is the discriminator; metadata.claims is the raw verified payload. The framework never reads either — it's a passthrough slot, so adding a provider never changes the Subject type.

Rolling your own provider

Three escape hatches, in order of effort:

  1. oidcStrategy from @voltro/plugin-auth-oidc — for any IdP with a standard OIDC discovery document. Most modern providers (Okta, Keycloak, Cognito, Azure AD, Google) work with this; configure id, issuer, audience, tenantClaim and you're done.

  2. jwtBearerStrategy from @voltro/protocol/jwt directly — for non-OIDC bearer-token providers, custom audiences, or when you want absolute control over the JWKS URL + verification rules:

    import { jwtBearerStrategy } from '@voltro/protocol/jwt'
    
    const custom = jwtBearerStrategy({
      id: 'my-idp',
      jwksUrl: 'https://idp.example.com/.well-known/jwks.json',
      issuer:  'https://idp.example.com',
      audience: 'https://api.yourapp.com',
      cookieName: null,                                  // Bearer header only
      tenantIdFromClaims: (claims) => (claims['org_id'] as string) ?? null,
    })
  3. Custom AuthStrategy implementation — for non-JWT auth (cookies that resolve via a DB lookup, mTLS, …). See strategies for the contract.

Returning null from tenantIdFromClaims is a failed verdict — the strategy owns the request but can't satisfy the tenant invariant, so it won't silently produce a tenant-less Subject.