Authentication and Authorization in GraphQL
Implement auth in GraphQL using context-based authentication, custom directives, and field-level authorization patterns.
What you'll learn
- ✓How to authenticate users through GraphQL context
- ✓How to build custom directives for declarative authorization
- ✓How to implement field-level permissions and role-based access control
Prerequisites
- •Basic GraphQL knowledge
- •Understanding of JWT or session-based authentication
Authentication vs. Authorization
Authentication answers “who are you?” — verifying the user’s identity. Authorization answers “what can you do?” — determining what resources and actions the authenticated user is allowed to access. In GraphQL, authentication typically happens at the transport layer, while authorization happens at the resolver layer.
Context-Based Authentication
The standard approach is to extract and verify credentials in the context function, making the authenticated user available to all resolvers:
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import jwt from 'jsonwebtoken';
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();
app.use(
'/graphql',
express.json(),
expressMiddleware(server, {
context: async ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return { user: null };
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await db.users.findById(decoded.userId);
return { user };
} catch (err) {
return { user: null };
}
},
})
);
Notice that the context function does not throw on missing or invalid tokens. It simply sets user to null. This allows public queries to work while letting individual resolvers decide whether authentication is required.
Resolver-Level Authorization
The simplest authorization approach checks permissions directly in resolvers:
import { GraphQLError } from 'graphql';
function requireAuth(context) {
if (!context.user) {
throw new GraphQLError('You must be logged in', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return context.user;
}
function requireRole(context, role) {
const user = requireAuth(context);
if (user.role !== role) {
throw new GraphQLError(`Requires ${role} role`, {
extensions: { code: 'FORBIDDEN' },
});
}
return user;
}
const resolvers = {
Query: {
// Public: no auth required
posts: () => db.posts.findPublished(),
// Authenticated: any logged-in user
me: (_, __, context) => {
return requireAuth(context);
},
// Admin only
users: (_, __, context) => {
requireRole(context, 'ADMIN');
return db.users.findAll();
},
},
Mutation: {
updateProfile: (_, { input }, context) => {
const user = requireAuth(context);
return db.users.update(user.id, input);
},
deleteUser: (_, { id }, context) => {
requireRole(context, 'ADMIN');
return db.users.delete(id);
},
},
};
This works for small schemas but becomes repetitive as the API grows. Custom directives offer a more declarative approach.
Custom Auth Directives
Schema directives let you declare authorization requirements directly in the schema:
directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role {
USER
EDITOR
ADMIN
}
type Query {
posts: [Post!]! # Public
me: User! @auth # Any authenticated user
users: [User!]! @auth(requires: ADMIN) # Admin only
}
type Mutation {
createPost(input: CreatePostInput!): Post! @auth(requires: EDITOR)
deleteUser(id: ID!): Boolean! @auth(requires: ADMIN)
}
Implement the directive using a schema transformer:
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLError } from 'graphql';
const ROLE_HIERARCHY = {
ADMIN: 3,
EDITOR: 2,
USER: 1,
};
function authDirectiveTransformer(schema) {
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 = async (source, args, context, info) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const userLevel = ROLE_HIERARCHY[context.user.role] || 0;
const requiredLevel = ROLE_HIERARCHY[requiredRole] || 0;
if (userLevel < requiredLevel) {
throw new GraphQLError(
`Requires ${requiredRole} role`,
{ extensions: { code: 'FORBIDDEN' } }
);
}
return originalResolve(source, args, context, info);
};
return fieldConfig;
},
});
}
// Apply the transformer
let schema = makeExecutableSchema({ typeDefs, resolvers });
schema = authDirectiveTransformer(schema);
Field-Level Authorization
Sometimes authorization is not about roles but about data ownership. A user can see their own email but not other users’ emails:
const resolvers = {
User: {
email: (user, _, context) => {
// Users can see their own email, admins can see anyone's
if (context.user?.id === user.id || context.user?.role === 'ADMIN') {
return user.email;
}
return null;
},
phoneNumber: (user, _, context) => {
if (context.user?.id === user.id) {
return user.phoneNumber;
}
return null;
},
},
};
For a more structured approach, use an authorization layer:
class AuthorizationService {
constructor(currentUser) {
this.currentUser = currentUser;
}
canReadField(resource, field) {
const rules = {
User: {
email: (user) =>
this.isOwner(user) || this.hasRole('ADMIN'),
phoneNumber: (user) => this.isOwner(user),
salary: (user) =>
this.hasRole('HR') || this.hasRole('ADMIN'),
},
};
const rule = rules[resource]?.[field];
return rule ? rule : () => true;
}
isOwner(resource) {
return this.currentUser?.id === resource.id;
}
hasRole(role) {
return this.currentUser?.role === role;
}
}
// Add to context
context: async ({ req }) => {
const user = await authenticateUser(req);
return {
user,
auth: new AuthorizationService(user),
};
},
Authorization with Data Loaders
When authorization checks require database lookups, use DataLoaders to avoid N+1 permission checks:
context: async ({ req }) => {
const user = await authenticateUser(req);
return {
user,
permissionLoader: new DataLoader(async (resourceIds) => {
const permissions = await db.permissions.findByUserAndResources(
user.id,
resourceIds
);
const map = new Map(permissions.map(p => [p.resourceId, p]));
return resourceIds.map(id => map.get(id) || null);
}),
};
},
Login and Token Management
Handle authentication mutations for login and token refresh:
type Mutation {
login(email: String!, password: String!): AuthPayload!
refreshToken(token: String!): AuthPayload!
logout: Boolean! @auth
}
type AuthPayload {
accessToken: String!
refreshToken: String!
user: User!
}
const resolvers = {
Mutation: {
login: async (_, { email, password }) => {
const user = await db.users.findByEmail(email);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken, user };
},
},
};
Common Mistakes
Authorizing in the wrong layer. Do not rely on client-side field selection for security. Even if the client does not request email, the field resolver should still enforce authorization. An attacker can modify the query.
Leaking data through error messages. “User not found” vs. “Access denied” reveals whether a resource exists. Use consistent error messages when appropriate.
Skipping authorization on nested fields. If Post.author returns a User, ensure the User field resolvers still enforce authorization. Just because the parent was authorized does not mean every nested field is safe.
Summary
Authenticate in the context function by extracting and verifying tokens, setting the user on context for resolvers to consume. Authorize at the resolver level using helper functions for simple cases or custom schema directives for a declarative approach. For field-level permissions, implement an authorization service that checks ownership and roles. Always authorize at the server level, never trust client-side field selection, and use DataLoaders to batch permission checks.
Related articles
- Java Spring Security Basics: Authentication, Authorization, and Filters
A practical introduction to Spring Security. Understand the filter chain, configure authentication, set up authorization rules, and avoid common mistakes.
- GraphQL GraphQL Query Complexity Analysis and Depth Limiting
Protect your GraphQL API from expensive queries using query complexity scoring, depth limiting, and cost analysis techniques.
- GraphQL GraphQL Rate Limiting Strategies
Why GraphQL rate limiting is harder than REST and what to do about it. Covers query complexity analysis, depth limits, cost-based budgets, and per-field throttling.
- REST APIs REST API Authentication: API Keys, JWT, and OAuth 2.0
Learn the three most common REST API authentication methods. Compare API keys, JWT tokens, and OAuth 2.0 with working code examples and security best practices.