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.

specs/organization-resolvers.spec.ts asserts a member-role session is refused members:write, so a permission check that never denies anything fails the suite.

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.