GraphQL Authentication and Authorization Patterns: A Practical Guide
A practical guide to GraphQL authentication and authorization patterns, from tokens to directives, RLS, subscriptions, and federation.
Image used for representation purposes only.
Why GraphQL Security Feels Different
GraphQL collapses many endpoints into a single, expressive API. That power shifts security decisions—what a caller can do—closer to your schema and resolvers. Unlike REST, where authorization often lives in route middleware, GraphQL needs consistent, fine-grained controls at the field and object level, across queries, mutations, and subscriptions.
This guide distills practical authentication (authN) and authorization (authZ) patterns that scale from a single service to federated graphs.
AuthN vs. AuthZ at a Glance
- Authentication: Who is the caller? Establish identity (user, service, device) using cookies/sessions, JWTs, or OAuth2/OIDC tokens.
- Authorization: What is the caller allowed to do? Enforce rules such as RBAC, ABAC, or relationship-based access (ReBAC) at type, field, and data levels.
Keep these concerns separate in code: authenticate once per request, authorize many times per operation.
Transport and Token Choices
- Sessions + Cookies
- Pros: Server-side revocation, simple rotation.
- Cons: CSRF risk with cookie-based auth; set SameSite=strict or use double-submit tokens. Prefer HTTP-only, secure cookies.
- JWT Bearer Tokens
- Pros: Stateless, work well across services/federation.
- Cons: Key rotation, clock skew, and audience/issuer validation must be correct. Avoid long-lived access tokens; pair with short-lived access + rotating refresh tokens.
- OAuth2/OIDC
- 3-legged flows for end-users; client credentials for machine-to-machine. Validate issuer, audience, and scopes. Never send tokens in query strings.
General tips:
- Put tokens in the Authorization header (Authorization: Bearer
). - Prevent mixed-origin leakage with correct CORS.
- For CDNs, never cache private responses unless you explicitly key by identity and authorization context.
Build a Trustworthy GraphQL Context
Authenticate once, early, and attach identity and claims to context. Example (Node + TypeScript):
import { createServer } from 'http';
import { jwtVerify, createRemoteJWKSet } from 'jose';
import { ApolloServer } from '@apollo/server';
const jwks = createRemoteJWKSet(new URL(process.env.JWKS_URL!));
async function getUser(authHeader?: string) {
if (!authHeader?.startsWith('Bearer ')) return null;
const token = authHeader.slice('Bearer '.length);
const { payload } = await jwtVerify(token, jwks, {
issuer: process.env.TOKEN_ISSUER,
audience: process.env.TOKEN_AUDIENCE,
maxTokenAge: '15m', // short-lived access tokens
clockTolerance: 5, // seconds of skew
});
return {
sub: payload.sub,
roles: payload['roles'] ?? [],
scopes: (payload['scope'] as string | undefined)?.split(' ') ?? [],
tenantId: payload['tenant'] ?? null,
};
}
const server = new ApolloServer({ typeDefs, resolvers });
createServer(async (req, res) => {
const user = await getUser(req.headers['authorization'] as string | undefined).catch(() => null);
const context = { user, requestId: crypto.randomUUID() };
// pass context to your GraphQL handler
});
Key details:
- Validate issuer, audience, and expiration; accept small clock skew.
- Enrich context with tenant, roles, and scopes; avoid re-decoding tokens in resolvers.
- Never share per-request caches (like DataLoader) across users or requests.
Authorization Strategies (From Simple to Strong)
1) Resolver-Level Checks (Imperative)
Straightforward and explicit; easy to audit for critical fields.
const resolvers = {
Query: {
invoice: async (_, { id }, ctx) => {
ctx.authz.requireRole('ACCOUNTANT');
const inv = await ctx.db.invoice.findById(id);
ctx.authz.assertOwnershipOrRole(inv.tenantId, ['ACCOUNTANT', 'ADMIN']);
return inv;
},
},
};
Pros: Clear intent where it matters. Cons: Can become noisy; risk of inconsistent coverage.
2) Schema Directives (@auth)
Declarative rules attached to schema types/fields.
directive @requireAuth(roles: [Role!] = [], anyScope: [String!] = []) on FIELD_DEFINITION | OBJECT
type Invoice @requireAuth(roles: [ACCOUNTANT, ADMIN]) {
id: ID!
amount: Int!
tenantId: ID!
}
type Query {
invoice(id: ID!): Invoice @requireAuth(roles: [ACCOUNTANT, ADMIN])
}
A directive transformer or resolver wrapper enforces checks before executing the field. Pros: Centralized, DRY. Cons: Requires careful implementation and testing to avoid bypasses.
3) Resolver Composition / Policy Middleware
Use libraries that compose permission rules (e.g., rule-based middlewares) to keep concerns separate.
Conceptually:
const isAuthenticated = rule(() => !!ctx.user);
const hasRole = (r: string) => rule(() => ctx.user?.roles.includes(r));
const permissions = shield({
Query: {
me: isAuthenticated,
adminStats: hasRole('ADMIN'),
},
});
Pros: Reusable rules and good testability. Cons: Another abstraction to learn; watch for performance at field granularity.
4) Data-Layer Enforcement (Row/Column Level)
Complement resolver checks with database policies:
- Use row-level security (RLS) to enforce tenant and ownership constraints.
- Parameterize queries with tenantId from context.
- For microservices, enforce at the service boundary too—GraphQL should not be your sole line of defense.
5) Policy Engines and ReBAC
For complex, evolving needs:
- RBAC: Simple roles (e.g., ADMIN, EDITOR). Easy to cache, quick to evaluate.
- ABAC: Evaluate attributes (user.department == resource.department && scope includes ‘read:invoice’).
- ReBAC: Relationship graphs (user is member of group that owns resource). Useful for org hierarchies and sharing models.
External policy engines (e.g., OPA-like approaches) let you update policies without redeploying your API. Cache decisions per-request and include policy version in logs.
Field-, Type-, and Operation-Level Controls
- Deny by default; allow list by necessity.
- Prefer guarding at the highest safe granularity:
- Type-level rules for entire object families.
- Field-level for sensitive attributes (e.g., salary, PII).
- Operation-level (query/mutation names) for coarse gates and auditing.
- After fetching a resource, re-check ownership or tenant constraints before returning it. Avoid assuming “if I could load it, I must be allowed to see it.”
Multi-Tenancy Without Data Leaks
- Carry tenantId in tokens and context; never accept it from client inputs for authorization.
- Scope database queries by tenantId; create composite indexes (tenantId, id).
- Isolate subscription topics by tenant; never broadcast cross-tenant.
- Prefer opaque, non-guessable IDs at the API edge; avoid exposing sequential database IDs across tenants.
Subscriptions and Real-Time Auth
WebSocket connections are long-lived—treat them like sessions.
- Authenticate during connection init; verify and store claims in connection context.
- Reauthorize on each event publish or at least on subscription start; a user’s role may change during a long session.
- Handle token refresh: allow a “refresh” message or reconnect flow.
- Partition topics by resource/tenant, e.g., invoiceUpdates:${tenantId}:${invoiceId}.
Pseudo-handler:
const onConnect = async (params) => {
const token = params?.Authorization?.replace('Bearer ', '');
const user = token ? await verify(token) : null;
if (!user) throw new Error('Unauthenticated');
return { user };
};
const subscribe = {
invoiceUpdated: {
subscribe: withFilter(
(_, { id }, ctx) => pubsub.asyncIterator(`invoice:${ctx.user.tenantId}:${id}`),
async (payload, vars, ctx) => ctx.authz.canReadInvoice(vars.id)
),
},
};
Performance: Make Security Cheap
- Authorize once per resolver path, not redundantly per child if the rule is identical.
- Memoize policy checks per resource in the request context.
- Instantiate DataLoaders per request to avoid cross-user cache bleed.
- Keep auth tokens short-lived and verifiers (JWKS) cached; warm them at startup.
Protect the Edge and Tooling
- Operation names: require them for all client calls; include in logs and traces.
- Persisted queries: whitelist hashed operations to reduce injection risk and improve CDN behavior.
- Introspection: restrict to trusted roles or environments; don’t rely solely on disabling it—assume attackers can learn your schema.
- Query cost controls: depth/complexity limits and timeout budgets per identity.
- Rate limiting and anomaly detection: key by user/tenant/client-app.
Auditing and Observability
Log who did what, to which resource, and why it was allowed/denied.
Structure logs with:
- requestId, operationName, variables hash (not raw PII),
- subject (sub), tenantId, roles/scopes,
- decision (allow/deny), policy/version,
- resource identifiers touched, record counts, latency.
Testing Your Authorization Model
- Unit-test rules (RBAC/ABAC/ReBAC) with positive and negative cases.
- Resolver tests for sensitive fields: assert denies when roles/tenants don’t match.
- Contract tests for federated services: simulate missing/forged claims.
- Subscription tests: ensure no cross-tenant event leakage.
- Fuzz tests: randomized variable values and selection sets to shake out gaps.
A Pragmatic Blueprint
- Authenticate at the edge; attach a typed identity to context.
- Choose a primary authorization style (e.g., directives + imperative checks for special cases).
- Enforce tenant and ownership both in resolvers and the data store (RLS or service policies).
- Gate tooling and operations (persisted queries, cost limits, introspection control).
- Log decisions and metrics; test denies as rigorously as allows.
Example: A Balanced Setup
- OAuth2/OIDC for user login; short-lived access token + rotating refresh token.
- Gateway verifies tokens and injects claims into downstream requests (headers like x-user-sub, x-tenant-id).
- Schema directives for broad rules (@requireAuth on types/fields).
- Imperative checks in resolvers for high-risk mutations (money movement, privilege changes).
- Database RLS ensures tenant scoping even if a resolver slips.
- Subscriptions authenticate on connect; topics partitioned by tenant and resource.
- Observability: operation names required, authorization decisions logged, dashboards for deny rates.
Common Pitfalls (and Fixes)
- Putting tokens in localStorage → Prefer HTTP-only cookies or memory plus secure storage, depending on threat model.
- Trusting client-sent tenantId → Derive tenant from verified claims.
- Caching DataLoader globally → Create per-request instances.
- Long-lived JWTs without revocation → Keep short lifetimes; maintain a denylist or rotate keys aggressively.
- Skipping audience/issuer checks → Always validate both.
- Over-relying on introspection disable → Still secure the schema; use persisted queries and role-gated introspection.
Closing Thoughts
GraphQL amplifies both the surface area and the opportunity for precise, auditable security. Treat authentication as a strict, well-validated contract at the edge, then layer authorization where it provides the strongest guarantees: schema, resolvers, and data store. With clear patterns, you’ll achieve least privilege without sacrificing developer velocity or performance.
Related Posts
Mastering GraphQL Relay Pagination with Connections
Master Relay-style pagination in GraphQL with connections, cursors, and PageInfo. Learn schema design, keyset pagination, resolvers, and pitfalls.
Stop the N+1 Spiral: The GraphQL DataLoader Batching Pattern, Explained
A practical guide to GraphQL DataLoader batching: fix N+1, design robust loaders, TypeScript examples, caching, observability, and production pitfalls.
The definitive guide to API resource naming conventions
Clear rules for naming API resources, fields, and events across REST, GraphQL, and gRPC—with examples, pitfalls, and a practical checklist.