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 |
AUTH_DEV_LOGIN_ENABLED | true to enable the development sign-in seam. Refused when ENV=production | Local/e2e only |
AUTH_DEV_LOGIN_SECRET | Signing key for the dev session cookie | Local/e2e only |
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
There is no headless equivalent of AuthKit's hosted sign-in, so local development and the
e2e suite use a development seam instead. With AUTH_DEV_LOGIN_ENABLED=true:
/auth/signinrenders a local email-only form instead of redirecting to WorkOS.POST /api/auth/dev-logincreates the user and their personal workspace through the same functions the real callback uses, then sets a signed session cookie.- The
WorkosDirectoryport switches to its local adapter, so creating organizations and inviting members writes mirror rows without calling WorkOS.
Everything downstream — the proxy, onboarding, resolvers, permissions — is the production code path. Only the credential differs.
Two things keep it from being a back door: it is refused outright when ENV === "production"
(ENV, not NODE_ENV, because Next inlines NODE_ENV at build time), and the cookie is our
own signed JWT rather than a forged AuthKit sealed session.
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.