# WorkOS

> WorkOS AuthStrategy — verifies WorkOS AuthKit / SSO JWTs via JWKS (no API key) and maps org_id → tenantId.



---

<!-- source: en/plugins/auth-workos.md -->
## WorkOS

_WorkOS AuthStrategy — verifies WorkOS AuthKit / SSO JWTs via JWKS (no API key) and maps org_id → tenantId._

`@voltro/plugin-auth-workos` integrates [WorkOS](https://workos.com) AuthKit / SSO: an `AuthStrategy` (conforming to `@voltro/protocol`) that verifies WorkOS-issued JWTs against the **JWKS endpoint** (public-key verify — no API key), maps `org_id` to the framework's `tenantId`, and composes with other strategies (your password cookie, an API key, another IdP). It's a thin specialization of the shared `jwtBearerStrategy` — it adds WorkOS's defaults and performs no verification of its own. Verified claims arrive on the Subject as `metadata.provider === 'workos'` + `metadata.claims`.

## Install

```
pnpm add @voltro/plugin-auth-workos
```

## Wiring

Add `workosStrategy(...)` to `auth.strategies` in your api's `app.config.ts`. The built-in signed-cookie password strategy always runs first; your strategies run after it, in order, until one `matched`s (or one `failed`s).

```ts
// app.config.ts
import { workosStrategy } from '@voltro/plugin-auth-workos'

export default {
  type: 'api' as const,
  name: 'myApi',
  store: 'postgres' as const,
  auth: {
    strategies: [
      workosStrategy({
        clientId: process.env.WORKOS_CLIENT_ID!,   // client_01H… — builds JWKS URL + audience
        // defaultTenantId: 'public',               // fallback when claims expose no org_id
      }),
    ],
  },
}
```

`clientId` is the only required option. From it: the JWKS URL defaults to `https://api.workos.com/sso/jwks/<clientId>`, the issuer to `https://api.workos.com`, and the **audience to the `clientId`**. The cookie defaults to `wos-session` (set by AuthKit's cookie-mode SDK; pass `null` for header-only).

Tenant resolution maps from `claims.org_id`, then `defaultTenantId`. Supply `tenantIdFromClaims(claims)` to derive it (returning `null` is a `failed` verdict). Verified claims arrive as `WorkosClaims` (`sub`, `email`, `org_id`, `role`, `permissions`, …).

To accept your own password sessions AND WorkOS during a migration, build the chain explicitly with `composeAuthStrategies` (see [auth strategies](/docs/authentication/strategies)):

```ts
import { composeAuthStrategies } from '@voltro/protocol'
import { voltroPasswordStrategy } from '@voltro/plugin-auth'
import { workosStrategy } from '@voltro/plugin-auth-workos'

const resolve = composeAuthStrategies([
  voltroPasswordStrategy(),
  workosStrategy({ clientId: process.env.WORKOS_CLIENT_ID! }),
])
```

## Options

| Option | Default | Notes |
|---|---|---|
| `clientId` | — (**required**) | `client_01H…`. Builds JWKS URL + audience. |
| `jwksUrl` | `https://api.workos.com/sso/jwks/<clientId>` | Override. |
| `issuer` | `https://api.workos.com` | Override. |
| `cookieName` | `'wos-session'` | AuthKit cookie mode; `null` for header-only. |
| `tenantIdFromClaims` | (uses `org_id`) | Derive `tenantId`; `null` → `failed`. |
| `defaultTenantId` | (none) | Fallback when claims expose no `org_id`. |
| `scopesFromClaims` | (none) | Map claims (AuthKit's `permissions[]` / `role`) → `Subject.scopes`. |

The audience is the `clientId`; algorithms are pinned to `['RS256', 'ES256']` (no alg-confusion).

## Environment variables

None read directly — pass `clientId` explicitly (typically from your own `defineEnv`-declared vars).

## Security

Server-only; fails closed — a forged, expired, or wrong-audience token, or one with no resolvable tenant, yields `failed`, never `matched`. JWKS public-key verify only; no WorkOS API key is ever handled.

## Hosted-login primitives (SSO login)

Beyond the verify-only `workosStrategy`, the package exports two primitives for the **hosted-login** flow — WorkOS logs the user in and your app mints its *own* session (an alternate front door alongside password login, not a replacement session authority):

- **`workosBeginLogin({ clientId, redirectUri })`** → `{ url, state, codeVerifier }`. The AuthKit login URL always carries a minted CSRF `state` **and** a PKCE `code_challenge` (S256); stash the other two for the callback.
- **`workosAuthenticateWithCode({ clientId, apiKey, code, state, expectedState, codeVerifier })`** → verifies the state in constant time (throwing `WorkosStateMismatchError` on a mismatch, before any network call), then exchanges the callback `code` for a `WorkosProfile` (`workosUserId`, `email`, `firstName`, `lastName`, `organizationId`). Server-only (carries the API key).

Neither `state` nor PKCE is optional: `state` used to be a parameter you could pass, so the default flow had no CSRF token, and the exchange verified nothing. The check now lives inside the only function that can redeem a code — a generated `state` that nothing verifies is worse than none.

Both are transport-thin (raw `fetch`, no `@workos-inc/node` dependency). Wire them into a `GET /auth/workos/login` + `GET /auth/workos/callback` route pair, find-or-provision your user in the callback, then `issueSession(...)`. Full worked example: [WorkOS SSO login](/docs/authentication/external-idp#workos-sso-login).

## Passwordless (Magic Auth)

For **passwordless** login — an email one-time code, no password and no browser redirect — the package exports the WorkOS Magic Auth pair:

- **`workosSendMagicAuthCode({ apiKey, email })`** → creates + emails a one-time code to `email`. Server-only (the API key is sent as a Bearer token).
- **`workosAuthenticateWithMagicAuth({ clientId, apiKey, email, code })`** → verifies the submitted `code` and returns the same `WorkosProfile` as the code exchange.

Wire them into a `POST /auth/passwordless/start` (send the code) + `POST /auth/passwordless/verify` (verify → find-or-provision → `issueSession(...)`) route pair — the same mint-your-own-session model as the hosted-login flow, without the redirect. Both are transport-thin (raw `fetch`).

## See also

- [External identity providers](/docs/authentication/external-idp)
- [Auth plugin](/docs/plugins/auth)
