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
- The proxy resolves the session.
apps/web/src/proxy.tsruns 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. - The context is built from the viewer.
createContextcallsgetViewer()— the single place the application resolves a session — which translates the WorkOS subject into the localusersrow and reads the token's permission claims. - Resolvers check explicitly. Each resolver calls
requireUserorrequirePermission.
Context
Every resolver receives a context object with:
req— the original Next.js requestisAuthenticated— whether the request is authenticateduserId— the localusers.id. Never a WorkOS id, and never falls back to an emailorganizationId— the local id of the session's active organizationuser/organization/role— the resolved rows and the caller's WorkOS role slugpermissions— the permission slugs granted to this sessionhasPermission(permission)— whether a permission is grantedcanAccessResource(ownerId)— whether the caller owns a resource
See apps/web/src/lib/graphql/context.ts for the full implementation.
Permissions
| Permission | Description |
|---|---|
org:read | Read an organization |
org:write | Change organization settings |
org:delete | Delete an organization |
members:read | List organization members |
members:write | Invite, change roles, remove members |
keys:read / keys:write | Reserved for API keys |
billing:manage | Reserved 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
| Situation | Result |
|---|---|
| Not authenticated | GraphQL error, code UNAUTHENTICATED, HTTP 401 |
| Missing permission | GraphQL error, code FORBIDDEN, HTTP 403 |
| No active organization | GraphQL error, code FORBIDDEN |
| Not a member of the target organization | Response wrapper with error code NOT_FOUND |
| Domain rule violated | Response 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 authenticatedrequirePermission(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.