Courses / GraphQL Complete Guide
Lesson 9 of 18
GraphQL Authentication and Authorization
Implement secure auth in GraphQL using context-based authentication, custom directives, and field-level permission patterns.
What you'll learn
- ✓How to authenticate users through GraphQL context
- ✓How to build custom schema directives for declarative authorization
- ✓How to implement role-based and field-level access control
- ✓How to secure mutations and subscriptions
- ✓How to handle auth errors consistently across your API
Prerequisites
- •Basic GraphQL knowledge
- •Understanding of JWT or session-based authentication
Authentication vs Authorization
Authentication verifies identity — who are you? Authorization checks permissions — what are you allowed to do? In GraphQL, authentication typically happens at the transport layer (HTTP headers, cookies), while authorization happens at the resolver layer (checking roles and permissions before returning data).
Context-Based Authentication
The standard pattern extracts and verifies credentials in the context function, making the authenticated user available to every resolver:
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import jwt from 'jsonwebtoken';
import express from 'express';
const app = express();
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, db };
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await db.users.findById(decoded.userId);
return { user, db };
} catch (err) {
// Token expired or invalid -- treat as unauthenticated
return { user: null, db };
}
},
})
);
Resolvers access the user from context:
const resolvers = {
Query: {
me: (_, __, { user }) => {
if (!user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return user;
},
},
};
Login and Token Management
Implement login as a mutation that returns a JWT:
type AuthPayload {
token: String!
user: User!
}
type Mutation {
login(email: String!, password: String!): AuthPayload!
register(input: RegisterInput!): AuthPayload!
refreshToken: AuthPayload!
}
input RegisterInput {
name: String!
email: String!
password: String!
}
import bcrypt from 'bcryptjs';
const resolvers = {
Mutation: {
login: async (_, { email, password }, { db }) => {
const user = await db.users.findByEmail(email);
if (!user) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
return { token, user };
},
register: async (_, { input }, { db }) => {
const existing = await db.users.findByEmail(input.email);
if (existing) {
throw new GraphQLError('Email already in use', {
extensions: { code: 'CONFLICT' },
});
}
const passwordHash = await bcrypt.hash(input.password, 12);
const user = await db.users.create({
name: input.name,
email: input.email,
passwordHash,
role: 'USER',
});
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
return { token, user };
},
refreshToken: async (_, __, { user }) => {
if (!user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
return { token, user };
},
},
};
Resolver-Level Authorization
The simplest authorization approach checks permissions directly in resolvers:
const resolvers = {
Query: {
users: (_, __, { user }) => {
if (!user || user.role !== 'ADMIN') {
throw new GraphQLError('Admin access required', {
extensions: { code: 'FORBIDDEN' },
});
}
return db.users.findAll();
},
},
Mutation: {
deleteUser: async (_, { id }, { user, db }) => {
if (!user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
// Users can delete themselves, admins can delete anyone
if (user.id !== id && user.role !== 'ADMIN') {
throw new GraphQLError('Not authorized to delete this user', {
extensions: { code: 'FORBIDDEN' },
});
}
return db.users.delete(id);
},
},
};
This works but leads to repetitive auth checks scattered across resolvers. Extract it into a helper:
function requireAuth(user) {
if (!user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return user;
}
function requireRole(user, ...roles) {
requireAuth(user);
if (!roles.includes(user.role)) {
throw new GraphQLError(`Requires one of: ${roles.join(', ')}`, {
extensions: { code: 'FORBIDDEN' },
});
}
return user;
}
// Clean resolvers
const resolvers = {
Query: {
users: (_, __, { user }) => {
requireRole(user, 'ADMIN');
return db.users.findAll();
},
me: (_, __, { user }) => {
return requireAuth(user);
},
},
};
Schema Directives for Declarative Auth
Schema directives move authorization logic into the schema itself, making permissions visible and declarative:
directive @auth(requires: Role = USER) on FIELD_DEFINITION | OBJECT
directive @owner on FIELD_DEFINITION
enum Role {
USER
EDITOR
ADMIN
}
type Query {
me: User @auth
users: [User!]! @auth(requires: ADMIN)
publicPosts: [Post!]!
}
type User @auth {
id: ID!
name: String!
email: String! @owner
role: Role! @auth(requires: ADMIN)
}
Implement the directives using schema transforms:
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
const ROLE_HIERARCHY = { USER: 0, EDITOR: 1, ADMIN: 2 };
function authDirectiveTransformer(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (authDirective) {
const requiredRole = authDirective.requires || 'USER';
const originalResolve = fieldConfig.resolve;
fieldConfig.resolve = async (source, args, context, info) => {
const { user } = context;
if (!user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
if (ROLE_HIERARCHY[user.role] < ROLE_HIERARCHY[requiredRole]) {
throw new GraphQLError(
`Requires ${requiredRole} role`,
{ extensions: { code: 'FORBIDDEN' } }
);
}
return originalResolve
? originalResolve(source, args, context, info)
: source[info.fieldName];
};
}
return fieldConfig;
},
});
}
// Apply the transformer
let schema = makeExecutableSchema({ typeDefs, resolvers });
schema = authDirectiveTransformer(schema);
Field-Level Permissions
Some fields should only be visible to certain users. The email field on a User should only be visible to the user themselves or an admin:
function ownerDirectiveTransformer(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const ownerDirective = getDirective(schema, fieldConfig, 'owner')?.[0];
if (ownerDirective) {
const originalResolve = fieldConfig.resolve;
fieldConfig.resolve = async (source, args, context, info) => {
const { user } = context;
if (!user) {
return null; // Hide field from unauthenticated users
}
// Allow if the user owns the resource or is an admin
const resourceOwnerId = source.id || source.userId;
if (user.id !== resourceOwnerId && user.role !== 'ADMIN') {
return null; // Hide field from non-owners
}
return originalResolve
? originalResolve(source, args, context, info)
: source[info.fieldName];
};
}
return fieldConfig;
},
});
}
Subscription Authentication
WebSocket connections authenticate during the handshake, not per-message:
import { useServer } from 'graphql-ws/use/ws';
useServer(
{
schema,
context: async (ctx) => {
const token = ctx.connectionParams?.authorization;
if (!token) {
throw new Error('Missing auth token');
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await db.users.findById(decoded.userId);
if (!user) throw new Error('User not found');
return { user, db };
} catch (err) {
throw new Error('Invalid auth token');
}
},
onConnect: async (ctx) => {
// Returning false rejects the connection
if (!ctx.connectionParams?.authorization) {
return false;
}
},
},
wsServer
);
On the client, pass the token through connection params:
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:4000/graphql',
connectionParams: () => ({
authorization: `Bearer ${getToken()}`,
}),
})
);
Permission-Based Authorization
For complex applications, role-based access control (RBAC) is too coarse. Use permission-based checks:
const PERMISSIONS = {
ADMIN: ['read:users', 'write:users', 'delete:users', 'read:analytics',
'write:posts', 'delete:posts', 'moderate:comments'],
EDITOR: ['read:users', 'write:posts', 'moderate:comments'],
USER: ['read:users', 'write:posts'],
};
function requirePermission(user, permission) {
requireAuth(user);
const userPermissions = PERMISSIONS[user.role] || [];
if (!userPermissions.includes(permission)) {
throw new GraphQLError(`Missing permission: ${permission}`, {
extensions: { code: 'FORBIDDEN', requiredPermission: permission },
});
}
}
const resolvers = {
Mutation: {
deletePost: async (_, { id }, { user, db }) => {
requirePermission(user, 'delete:posts');
return db.posts.delete(id);
},
viewAnalytics: async (_, __, { user, db }) => {
requirePermission(user, 'read:analytics');
return db.analytics.getSummary();
},
},
};
Security Best Practices
Never trust the client. Always validate permissions server-side. Even if the client hides certain UI elements, the GraphQL endpoint is accessible to anyone with the URL.
Use HTTPS. JWTs in transit are readable by anyone sniffing the network without TLS.
Set short token expiry. Use short-lived access tokens (15-60 minutes) with refresh tokens for session extension. This limits the damage window if a token is compromised.
Rate limit authentication mutations. Login and register endpoints are brute-force targets. Apply rate limiting specifically to these operations.
Log authentication events. Record successful logins, failed attempts, and permission denials. This audit trail is essential for security incident investigation.
Sanitize error messages. Do not reveal whether an email exists during login. Use generic “Invalid credentials” messages for both wrong email and wrong password.
Progress is saved locally to your browser.