Skip to content
Codeloom
GraphQL

Authentication and Authorization in GraphQL

Implement auth in GraphQL using context-based authentication, custom directives, and field-level authorization patterns.

·6 min read · By Codeloom
Intermediate 11 min read

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.