Authentication
The web application authenticates with WorkOS AuthKit. AuthKit is the source of truth for identity: users, credentials, email verification, organizations, memberships, roles, and invitations all live there. We keep a thin mirror in Postgres so our own tables can carry foreign keys without a network call.
What AuthKit gives us
- Email + password, social login, passkeys, MFA, and Magic Auth — all included, none of it ours to maintain.
- Enterprise SSO and Directory Sync when we need them, without a second integration.
- Organizations, memberships, and role-based permissions, with the active organization and its permission slugs carried as signed claims on the session's access token.
- Invitations, including to email domains we don't own.
Architecture
| File | Role |
|---|---|
apps/web/src/proxy.ts | Resolves the session on every request and applies the route policy. Next 16 calls this proxy.ts; AuthKit's matching export is authkitProxy, but we compose the lower-level authkit() so the policy stays testable. |
apps/web/src/route-policy.ts | Pure routing decisions — which routes are gated, and where an ungated request goes. Unit-tested without any AuthKit import. |
apps/web/src/lib/auth/session.ts | getViewer() — the single place the app asks who is making a request. Translates WorkOS ids to local rows and resolves permissions. |
apps/web/src/lib/auth/directory.ts | The WorkosDirectory port: creating organizations, inviting members, changing roles. Two adapters — the real WorkOS API, and a local one for tests. |
apps/web/src/lib/auth/actions.ts | Server actions for sign-out and organization switching. |
apps/web/src/app/api/auth/callback/route.ts | Where AuthKit returns after authentication. Mirrors the user and ensures they have a workspace. |
apps/web/src/app/api/auth/bootstrap/route.ts | Claims an active organization for a session that has none, re-issuing the access token. |
apps/web/src/app/api/webhooks/workos/route.ts | Keeps the mirror in step with WorkOS. |
The sign-in flow
- An unauthenticated request for a protected route is redirected to
/auth/signin. - That route redirects to AuthKit's hosted sign-in page (
getSignInUrl()). - AuthKit returns to
/api/auth/callback, which seals the session cookie. ItsonSuccesshook mirrors the user and creates their personal workspace if they don't have one. - The first token was issued before that workspace existed, so it carries no
org_id. The proxy notices and routes the next protected request through/api/auth/bootstrap, which callsrefreshSession({ organizationId })and re-issues the token withorg_id,role, andpermissionsclaims. - The
(app)template redirects to/onboardinguntilusers.onboarded_atis set.
Step 4 exists because only a route handler may write the session cookie — doing it during a server-component render throws. It is also self-healing: any session that loses its organization is routed back through the same path.
Authorization
Permissions come from the permissions claim on the access token — the copy WorkOS signed.
The mirrored organization_memberships.role column is for display and joins only; it can lag
a webhook, so it must never be able to grant access.
Slugs are defined in packages/shared-types/src/lib/core.ts:
| Permission | Grants |
|---|---|
org:read | Read an organization |
org:write | Change organization settings |
org:delete | Delete an organization |
members:read | List members |
members:write | Invite, change roles, remove members |
keys:read / keys:write | Reserved for API keys |
billing:manage | Reserved for billing |
Built-in roles: owner (everything), admin (everything but org:delete and
billing:manage), member (org:read, members:read). Organizations may define custom
roles; permissionsForRole() resolves an unrecognised slug to the narrowest role rather than
failing open.
Enforce a permission in a resolver with requirePermission(context, "members:write") from
lib/graphql/common/guards.ts, which narrows the context so userId and user are non-null
afterwards. Server components resolve the viewer with getViewer() and check
viewer.permissions directly — there is deliberately no second authorization API.
Keep slugs short: they ride in the session cookie (~4KB browser ceiling) and JWT templates must render under 3072 bytes.
Environment variables
| Variable | Description | Required |
|---|---|---|
WORKOS_API_KEY | WorkOS secret API key | Yes |
WORKOS_CLIENT_ID | WorkOS client id | Yes |
WORKOS_COOKIE_PASSWORD | Session-sealing key, 32+ characters (openssl rand -base64 32) | Yes |
| (no redirect URI variable) | The callback is derived from the request host — see below. Register every host in the WorkOS dashboard. | — |
WORKOS_WEBHOOK_SECRET | Signing secret for the webhook endpoint | For webhooks |
WORKOS_COOKIE_MAX_AGE | Session lifetime in seconds. Set it — the SDK default is 400 days | Recommended |
WORKOS_COOKIE_DOMAIN | Cookie domain, for sharing a session across subdomains | Optional |
AUTHKIT_DEBUG | true to log AuthKit's proxy decisions | Optional |
WorkOS dashboard setup
Each environment (staging, production) needs its own WorkOS environment:
-
Redirect URI — add
<origin>/api/auth/callbackfor every origin that will sign users in, includinghttp://localhost:3000for local development. WorkOS requires exact URIs, so each per-PR preview host needs registering too.There is no environment variable for this. The SDK reads
NEXT_PUBLIC_WORKOS_REDIRECT_URI, but Next inlines everyNEXT_PUBLIC_*reference at build time — so it is baked into the image, and one image cannot serve two hostnames. Preview deployments retag a singlewebimage across per-PR hosts, which makes any baked value wrong for most of them. The app therefore derives the callback from the request's forwarded host (src/lib/auth/redirect-uri.ts) and passes it explicitly.That means the host header decides the redirect URI, which is only safe because WorkOS rejects any URI not on this registered list. The list is the enforcement point; keep it tight.
-
Roles — create
owner,admin, andmemberas environment roles, and attach the permission slugs above.membershould be the environment default. -
Permissions — create each slug from the table above.
-
Webhooks — point an endpoint at
<origin>/api/webhooks/workossubscribed touser.*,organization.*, andorganization_membership.*, and put its signing secret inWORKOS_WEBHOOK_SECRET.
If roles exist but have no permissions attached yet, getViewer() falls back to deriving
permissions from the signed role claim via the local ROLE_PERMISSIONS table, so a
half-configured dashboard doesn't lock everyone out.
Local development and tests
Local development and the e2e suite run the production code path against the official
WorkOS emulator instead of a real tenant: hosted
sign-in pages, code exchange, session refresh, organization writes, and signed webhooks all
behave as they do in production, served from an in-memory process on
http://localhost:4100.
- Start it with
nx run web:emulator; it seeds from/workos-emulate.config.yaml, which mirrors the real environments' roles and permission slugs (keep it in step withROLE_PERMISSIONS). - Point the app at it with
WORKOS_API_HOSTNAME=localhost,WORKOS_API_PORT=4100,WORKOS_API_HTTPS=false,WORKOS_API_KEY=sk_test_default, and any stableWORKOS_CLIENT_ID; hosted-page redirects follow automatically. - The hosted page signs in whichever address you type for an account that exists — the emulator is a test double, not an authorization server. Never point it at anything you would not let sign in as your users, and never expose it beyond localhost.
- State is in-memory: an emulator restart forgets sessions and runtime data (seeded records come back). Sign in again after a restart; wipe the local database when its mirrored rows should reset too.
- Two emulator divergences to know about: redirect URIs are accepted but not enforced (a misregistered callback URL fails only against real WorkOS — staging verification is the net), and refresh tokens always rotate (stricter than production, which surfaces forgotten-token bugs locally).
The e2e suite boots its own emulator on port 4101 with a per-run seed — see
apps/web/e2e/helpers/ and the e2e guide.
Data model
See packages/shared-db/prisma/schema.prisma. Every mirrored row carries its own id
alongside the workos*Id it shadows — WorkOS ids never appear in URLs or API payloads — and a
synced_at holding the upstream updated_at of the last change applied. WorkOS does not
guarantee webhook ordering, so writes compare against it and skip anything staler.
users.display_name, users.bio, and users.onboarded_at are ours; sync never overwrites
them. Everything else on a mirrored row belongs to WorkOS and is replaced wholesale.