Authentication
Authentication and authorization patterns for securing GraphQL APIs
You are an expert in securing GraphQL APIs, helping developers implement authentication, authorization, and access control using JWTs, middleware, directives, and field-level permissions. ## Key Points 1. **Resolver-level checks** — inline authorization in each resolver. 2. **Middleware/wrapper functions** — reusable authorization logic that wraps resolvers. 3. **Schema directives** — declarative authorization in the schema via custom directives. 1. **Authenticate at the transport layer, authorize in resolvers** — verify tokens in the context function; check permissions in resolvers or directives. 2. **Never trust the client** — always validate permissions server-side. Hiding UI elements is not a security measure. 3. **Use short-lived access tokens with refresh tokens** — access tokens should expire in 15 minutes or less, with a longer-lived refresh token for renewal. 4. **Apply defense in depth** — combine resolver-level checks with query complexity limits and rate limiting. 5. **Log authorization failures** — track denied access attempts for security monitoring and incident response. - **Storing secrets in JWTs** — JWTs are base64-encoded, not encrypted. Never put sensitive data in the payload. - **Not invalidating tokens on logout** — JWTs are stateless, so you need a blocklist or short expiry to handle logout and compromised tokens. - **Single authorization check for complex operations** — a user may be authorized to read a post but not its private analytics. Check permissions per field, not per query. - **Leaking information through error messages** — "User not found" vs "Not authorized" tells an attacker whether the resource exists. Use consistent error messages. ## Quick Example ``` Client → HTTP Header (Bearer token) → Context Function → Token Verification → User in Context → Resolvers ```
skilldb get graphql-skills/AuthenticationFull skill: 334 linesAuthentication — GraphQL
You are an expert in securing GraphQL APIs, helping developers implement authentication, authorization, and access control using JWTs, middleware, directives, and field-level permissions.
Overview
Authentication (who is the user?) and authorization (what can they do?) are critical in GraphQL APIs. Unlike REST, where endpoints map to resources, GraphQL exposes a single endpoint with a flexible query language — making access control both more important and more nuanced. Authorization must be applied at the field and resolver level, not at the route level.
Core Concepts
Authentication Flow
Authentication typically happens at the transport layer, before GraphQL execution begins:
Client → HTTP Header (Bearer token) → Context Function → Token Verification → User in Context → Resolvers
// Context function extracts and verifies the user
async function buildContext({ req }: { req: express.Request }): Promise<Context> {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
return { currentUser: null };
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
const user = await userLoader.load(payload.sub);
return { currentUser: user };
} catch (err) {
// Invalid or expired token — treat as unauthenticated
return { currentUser: null };
}
}
Authorization Strategies
There are three main approaches:
- Resolver-level checks — inline authorization in each resolver.
- Middleware/wrapper functions — reusable authorization logic that wraps resolvers.
- Schema directives — declarative authorization in the schema via custom directives.
Implementation Patterns
Resolver-Level Authorization
The simplest approach, suitable for small APIs:
const resolvers = {
Query: {
me: (_, __, { currentUser }) => {
if (!currentUser) throw new GraphQLError("Not authenticated", {
extensions: { code: "UNAUTHENTICATED" },
});
return currentUser;
},
adminDashboard: (_, __, { currentUser }) => {
if (!currentUser) throw new GraphQLError("Not authenticated", {
extensions: { code: "UNAUTHENTICATED" },
});
if (!currentUser.roles.includes("ADMIN")) {
throw new GraphQLError("Not authorized", {
extensions: { code: "FORBIDDEN" },
});
}
return getDashboardData();
},
},
};
Reusable Auth Wrappers
Extract authorization into composable higher-order functions:
function requireAuth<TParent, TArgs>(
resolver: (parent: TParent, args: TArgs, context: AuthenticatedContext, info: GraphQLResolveInfo) => any
) {
return (parent: TParent, args: TArgs, context: Context, info: GraphQLResolveInfo) => {
if (!context.currentUser) {
throw new GraphQLError("Authentication required", {
extensions: { code: "UNAUTHENTICATED" },
});
}
return resolver(parent, args, context as AuthenticatedContext, info);
};
}
function requireRole<TParent, TArgs>(role: string, resolver: Function) {
return requireAuth((parent: TParent, args: TArgs, context: AuthenticatedContext, info) => {
if (!context.currentUser.roles.includes(role)) {
throw new GraphQLError(`Role "${role}" required`, {
extensions: { code: "FORBIDDEN" },
});
}
return resolver(parent, args, context, info);
});
}
// Usage
const resolvers = {
Query: {
me: requireAuth((_, __, { currentUser }) => currentUser),
adminDashboard: requireRole("ADMIN", () => getDashboardData()),
users: requireRole("ADMIN", (_, args, { dataSources }) => dataSources.users.list(args)),
},
Mutation: {
updateProfile: requireAuth((_, { input }, { currentUser, dataSources }) =>
dataSources.users.update(currentUser.id, input)
),
},
};
Schema Directives for Authorization
Declare authorization requirements in the schema itself:
directive @auth(requires: Role = USER) on FIELD_DEFINITION | OBJECT
enum Role {
ADMIN
MODERATOR
USER
}
type Query {
me: User @auth
users: [User!]! @auth(requires: ADMIN)
publicPosts: [Post!]!
}
type User @auth {
id: ID!
email: String! @auth(requires: ADMIN)
displayName: String!
role: Role! @auth(requires: ADMIN)
}
Implement the directive transformer:
import { mapSchema, getDirective, MapperKind } from "@graphql-tools/utils";
function authDirectiveTransformer(schema: GraphQLSchema): GraphQLSchema {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, "auth")?.[0];
if (!authDirective) return fieldConfig;
const requiredRole = authDirective.requires ?? "USER";
const originalResolve = fieldConfig.resolve ?? defaultFieldResolver;
fieldConfig.resolve = (parent, args, context, info) => {
if (!context.currentUser) {
throw new GraphQLError("Authentication required", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const roleHierarchy = ["USER", "MODERATOR", "ADMIN"];
const userRoleIndex = roleHierarchy.indexOf(context.currentUser.role);
const requiredRoleIndex = roleHierarchy.indexOf(requiredRole);
if (userRoleIndex < requiredRoleIndex) {
throw new GraphQLError("Insufficient permissions", {
extensions: { code: "FORBIDDEN" },
});
}
return originalResolve(parent, args, context, info);
};
return fieldConfig;
},
});
}
Field-Level Visibility
Hide sensitive fields based on the viewer's relationship to the data:
const resolvers = {
User: {
email: (user, _, { currentUser }) => {
// Users can see their own email; admins can see all emails
if (currentUser?.id === user.id || currentUser?.role === "ADMIN") {
return user.email;
}
return null;
},
phoneNumber: (user, _, { currentUser }) => {
if (currentUser?.id === user.id) return user.phoneNumber;
return null;
},
},
};
Token Refresh Pattern
Handle expired tokens gracefully on the client side:
import { fromPromise } from "@apollo/client";
import { onError } from "@apollo/client/link/error";
let isRefreshing = false;
let pendingRequests: (() => void)[] = [];
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
const unauthError = graphQLErrors?.find(
(e) => e.extensions?.code === "UNAUTHENTICATED"
);
if (!unauthError) return;
if (!isRefreshing) {
isRefreshing = true;
return fromPromise(
refreshAccessToken()
.then((newToken) => {
// Retry pending requests
pendingRequests.forEach((cb) => cb());
pendingRequests = [];
return newToken;
})
.catch(() => {
pendingRequests = [];
logout();
return null;
})
.finally(() => {
isRefreshing = false;
})
).flatMap((newToken) => {
if (!newToken) return forward(operation); // Will fail, triggers logout
operation.setContext(({ headers = {} }) => ({
headers: { ...headers, authorization: `Bearer ${newToken}` },
}));
return forward(operation);
});
}
// Queue subsequent requests while refreshing
return fromPromise(
new Promise<void>((resolve) => pendingRequests.push(resolve))
).flatMap(() => forward(operation));
});
Rate Limiting by User
Apply per-user rate limits to prevent abuse:
import { RateLimiterMemory } from "rate-limiter-flexible";
const rateLimiter = new RateLimiterMemory({
points: 100, // 100 requests
duration: 60, // per 60 seconds
});
const rateLimitPlugin: ApolloServerPlugin<Context> = {
async requestDidStart({ contextValue }) {
const key = contextValue.currentUser?.id ?? "anonymous";
try {
await rateLimiter.consume(key);
} catch {
throw new GraphQLError("Rate limit exceeded", {
extensions: { code: "RATE_LIMITED" },
});
}
},
};
Core Philosophy
GraphQL security requires a fundamentally different mindset than REST security. In REST, each endpoint is a natural authorization boundary — you protect /admin/users differently from /public/posts. In GraphQL, every client talks to a single endpoint and can request arbitrary combinations of fields and relationships. This means authorization must be pushed down to the field and resolver level, not applied at the route level. The schema is your API surface, and every node in the graph is a potential access control decision.
The strongest authentication architectures separate identity verification from permission evaluation. Authentication (who are you?) happens once at the transport layer — the context function verifies the JWT, decodes the claims, and attaches the user to context. Authorization (what can you do?) happens repeatedly throughout query execution — each resolver or directive checks whether the authenticated user has the right to access that specific piece of data. This separation keeps the concerns clean and makes each layer independently testable.
Defense in depth is not optional. A single authorization check at the query root is insufficient because GraphQL's nested nature means sensitive data can be reached through multiple paths. Combine field-level permission checks with query complexity limits, rate limiting per user, and short-lived tokens with refresh rotation. No single layer is bulletproof, but together they create a security posture that is resilient to both accidental misuse and deliberate attack.
Anti-Patterns
-
Route-level-only authentication — protecting the
/graphqlendpoint with a middleware check and assuming all resolvers are therefore safe ignores the fact that different fields and mutations require different permission levels. A logged-in user should not automatically access admin data. -
Trusting client-side UI hiding as security — removing a button or hiding a form field on the client is not access control. Any user with a network inspector can craft arbitrary GraphQL operations. Authorization must always be enforced server-side.
-
Storing sensitive data in JWT payloads — JWTs are base64-encoded, not encrypted. Embedding secrets, internal IDs, or PII in the token payload exposes them to anyone who intercepts or inspects the token.
-
One-size-fits-all error messages for auth failures — returning the same vague "unauthorized" message for both missing authentication and insufficient permissions makes debugging impossible for legitimate users and provides no useful signal for monitoring systems.
-
Long-lived access tokens without refresh rotation — access tokens that last hours or days widen the window of exploitation if compromised. Use short-lived access tokens (15 minutes or less) paired with refresh tokens, and rotate refresh tokens on each use to detect theft.
Best Practices
- Authenticate at the transport layer, authorize in resolvers — verify tokens in the context function; check permissions in resolvers or directives.
- Never trust the client — always validate permissions server-side. Hiding UI elements is not a security measure.
- Use short-lived access tokens with refresh tokens — access tokens should expire in 15 minutes or less, with a longer-lived refresh token for renewal.
- Apply defense in depth — combine resolver-level checks with query complexity limits and rate limiting.
- Log authorization failures — track denied access attempts for security monitoring and incident response.
- Return null for unauthorized optional fields, throw for unauthorized required operations — failing silently for optional data is better UX; mutations and required data should throw clear errors.
Common Pitfalls
- Authorizing only at the Query level — nested resolvers like
User.emailalso need access control. A user querying their own profile should see their email, but querying another user's profile should not. - Storing secrets in JWTs — JWTs are base64-encoded, not encrypted. Never put sensitive data in the payload.
- Not invalidating tokens on logout — JWTs are stateless, so you need a blocklist or short expiry to handle logout and compromised tokens.
- Single authorization check for complex operations — a user may be authorized to read a post but not its private analytics. Check permissions per field, not per query.
- Leaking information through error messages — "User not found" vs "Not authorized" tells an attacker whether the resource exists. Use consistent error messages.
Install this skill directly: skilldb add graphql-skills
Related Skills
Apollo Client
Apollo Client with React for querying, mutating, and caching GraphQL data
Apollo Server
Apollo Server setup, configuration, plugins, and production deployment patterns
Code Generation
Type-safe GraphQL development with graphql-codegen for TypeScript
Pagination
Cursor-based pagination following the Relay connection specification
Resolvers
Resolver patterns, data loading strategies, and the N+1 problem in GraphQL
Schema Design
Schema design principles for building maintainable, intuitive GraphQL APIs