Skip to main content

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

FileRole
apps/web/src/proxy.tsResolves 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.tsPure routing decisions — which routes are gated, and where an ungated request goes. Unit-tested without any AuthKit import.
apps/web/src/lib/auth/session.tsgetViewer() — 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.tsrequireViewer() / requireOrganizationViewer() for server components and route handlers.
apps/web/src/lib/auth/directory.tsThe 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.tsServer actions for sign-out and organization switching.
apps/web/src/app/api/auth/callback/route.tsWhere AuthKit returns after authentication. Mirrors the user and ensures they have a workspace.
apps/web/src/app/api/auth/bootstrap/route.tsClaims an active organization for a session that has none, re-issuing the access token.
apps/web/src/app/api/webhooks/workos/route.tsKeeps the mirror in step with WorkOS.

The sign-in flow

  1. An unauthenticated request for a protected route is redirected to /auth/signin.
  2. That route redirects to AuthKit's hosted sign-in page (getSignInUrl()).
  3. AuthKit returns to /api/auth/callback, which seals the session cookie. Its onSuccess hook mirrors the user and creates their personal workspace if they don't have one.
  4. 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 calls refreshSession({ organizationId }) and re-issues the token with org_id, role, and permissions claims.
  5. The (app) template redirects to /onboarding until users.onboarded_at is 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:

PermissionGrants
org:readRead an organization
org:writeChange organization settings
org:deleteDelete an organization
members:readList members
members:writeInvite, change roles, remove members
keys:read / keys:writeReserved for API keys
billing:manageReserved 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

VariableDescriptionRequired
WORKOS_API_KEYWorkOS secret API keyYes
WORKOS_CLIENT_IDWorkOS client idYes
WORKOS_COOKIE_PASSWORDSession-sealing key, 32+ characters (openssl rand -base64 32)Yes
NEXT_PUBLIC_WORKOS_REDIRECT_URIMust match a redirect URI registered in WorkOS, e.g. http://localhost:3000/api/auth/callbackYes
WORKOS_WEBHOOK_SECRETSigning secret for the webhook endpointFor webhooks
WORKOS_COOKIE_MAX_AGESession lifetime in seconds. Set it — the SDK default is 400 daysRecommended
WORKOS_COOKIE_DOMAINCookie domain, for sharing a session across subdomainsOptional
AUTHKIT_DEBUGtrue to log AuthKit's proxy decisionsOptional
AUTH_DEV_LOGIN_ENABLEDtrue to enable the development sign-in seam. Refused when ENV=productionLocal/e2e only
AUTH_DEV_LOGIN_SECRETSigning key for the dev session cookieLocal/e2e only

WorkOS dashboard setup

Each environment (staging, production) needs its own WorkOS environment:

  1. Redirect URI — add <origin>/api/auth/callback for every origin that will sign users in, including http://localhost:3000 for local development. WorkOS requires exact URIs, so per-PR preview environments need either a registered URI each or a shared staging redirect.
  2. Roles — create owner, admin, and member as environment roles, and attach the permission slugs above. member should be the environment default.
  3. Permissions — create each slug from the table above.
  4. Webhooks — point an endpoint at <origin>/api/webhooks/workos subscribed to user.*, organization.*, and organization_membership.*, and put its signing secret in WORKOS_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/signin renders a local email-only form instead of redirecting to WorkOS.
  • POST /api/auth/dev-login creates the user and their personal workspace through the same functions the real callback uses, then sets a signed session cookie.
  • The WorkosDirectory port 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.