Skip to main content

Authentication & Authorization

The GraphQL API authenticates with WorkOS AuthKit and authorizes against the permission slugs WorkOS signs into the session's access token.

How authentication works

  1. The proxy resolves the session. apps/web/src/proxy.ts runs on every matched path, including /api/graphql, and hands the sealed session to the request. This is not optional: withAuth() throws in any route handler the proxy skipped.
  2. The context is built from the viewer. createContext calls getViewer() — the single place the application resolves a session — which translates the WorkOS subject into the local users row and reads the token's permission claims.
  3. Resolvers check explicitly. Each resolver calls requireUser or requirePermission.

Context

Every resolver receives a context object with:

  • req — the original Next.js request
  • isAuthenticated — whether the request is authenticated
  • userId — the local users.id. Never a WorkOS id, and never falls back to an email
  • organizationId — the local id of the session's active organization
  • user / organization / role — the resolved rows and the caller's WorkOS role slug
  • permissions — the permission slugs granted to this session
  • hasPermission(permission) — whether a permission is granted
  • canAccessResource(ownerId) — whether the caller owns a resource

See apps/web/src/lib/graphql/context.ts for the full implementation.

Permissions

PermissionDescription
org:readRead an organization
org:writeChange organization settings
org:deleteDelete an organization
members:readList organization members
members:writeInvite, change roles, remove members
keys:read / keys:writeReserved for API keys
billing:manageReserved for billing

Permissions come from the access token, not from the database. The mirrored organization_memberships.role column exists for display and joins; 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.

This replaced a DEFAULT_SCOPES constant that granted every scope to every authenticated user, which made requireScope() decorative. specs/organization-resolvers.spec.ts asserts a member-role session is refused members:write so that cannot return unnoticed.

Tenancy

Organization-scoped resolvers derive tenancy from membership, never from an argument. An organization id in a query is a request, not a claim: memberOrganization() checks for an active membership first, and a non-member gets NOT_FOUND rather than FORBIDDEN — whether a given organization exists is not theirs to learn.

Schema directive

The @auth directive documents which fields are gated. It is not implemented as a schema transformer — enforcement is imperative, in the resolver. Keep them in step.

  • @auth(requires: USER) — requires authentication
  • @auth(requires: ADMIN) — requires administrative access

Error handling

SituationResult
Not authenticatedGraphQL error, code UNAUTHENTICATED, HTTP 401
Missing permissionGraphQL error, code FORBIDDEN, HTTP 403
No active organizationGraphQL error, code FORBIDDEN
Not a member of the target organizationResponse wrapper with error code NOT_FOUND
Domain rule violatedResponse wrapper, e.g. LAST_OWNER, VALIDATION_ERROR

Authentication and authorization failures throw; domain outcomes are returned in the response wrapper so a client can render them.

Authorization in resolvers

Use the guards from apps/web/src/lib/graphql/common/guards.ts:

  • requireUser(context) — throws if not authenticated
  • requirePermission(context, permission) — throws unless the permission is granted

Both are TypeScript assertion functions, so afterwards context.userId and context.user are non-null without a cast.

For consumers outside the web app, shared-graphql exports the underlying requireAuth, requireScope, requireOrganization, and requireResourceAccess.