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/require.ts | requireViewer() / requireOrganizationViewer() for server components and route handlers. |
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 requireScope(context, "members:write"), or in a
server component with hasPermission(viewer, "members:write").
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 |
NEXT_PUBLIC_WORKOS_REDIRECT_URI | Must match a redirect URI registered in WorkOS, e.g. http://localhost:3000/api/auth/callback | Yes |
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 per-PR preview environments need either a registered URI each or a shared staging redirect. - 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.