Skip to content
Codeloom

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.

Intermediate 13 min read

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.