TechLead
Lesson 11 of 20
5 min read
GraphQL

Authentication in GraphQL

Implement authentication and authorization patterns in GraphQL including JWT tokens, context-based auth, and field-level permissions

Authentication vs Authorization

Authentication verifies who the user is (login, token validation). Authorization determines what the authenticated user is allowed to do (permissions, roles). In GraphQL, both are typically handled through the context object and resolver logic.

Authentication Strategies

  • JWT (JSON Web Tokens): Stateless, scalable, most common for GraphQL APIs
  • Session cookies: Traditional, works well for same-origin web apps
  • API keys: For service-to-service communication
  • OAuth 2.0: For third-party integrations

JWT Authentication Flow

# Authentication mutations
type Mutation {
  signUp(input: SignUpInput!): AuthPayload!
  signIn(email: String!, password: String!): AuthPayload!
  refreshToken(token: String!): AuthPayload!
}

type AuthPayload {
  accessToken: String!
  refreshToken: String!
  user: User!
  expiresIn: Int!
}

input SignUpInput {
  name: String!
  email: String!
  password: String!
}

# Protected query — requires authentication
type Query {
  me: User!
  myPosts: [Post!]!
}

Token Management in Resolvers

import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { GraphQLError } from 'graphql';

const ACCESS_TOKEN_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_TOKEN_SECRET = process.env.JWT_REFRESH_SECRET!;

function generateTokens(userId: string) {
  const accessToken = jwt.sign({ userId }, ACCESS_TOKEN_SECRET, {
    expiresIn: '15m',
  });
  const refreshToken = jwt.sign({ userId }, REFRESH_TOKEN_SECRET, {
    expiresIn: '7d',
  });
  return { accessToken, refreshToken, expiresIn: 900 };
}

const resolvers = {
  Mutation: {
    signUp: async (_: unknown, { input }: any, ctx: Context) => {
      const existingUser = await ctx.prisma.user.findUnique({
        where: { email: input.email },
      });
      if (existingUser) {
        throw new GraphQLError('Email already in use', {
          extensions: { code: 'BAD_USER_INPUT' },
        });
      }

      const hashedPassword = await bcrypt.hash(input.password, 12);
      const user = await ctx.prisma.user.create({
        data: {
          name: input.name,
          email: input.email,
          password: hashedPassword,
        },
      });

      const tokens = generateTokens(user.id);
      return { ...tokens, user };
    },

    signIn: async (_: unknown, { email, password }: any, ctx: Context) => {
      const user = await ctx.prisma.user.findUnique({ where: { email } });
      if (!user) {
        throw new GraphQLError('Invalid credentials', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }

      const validPassword = await bcrypt.compare(password, user.password);
      if (!validPassword) {
        throw new GraphQLError('Invalid credentials', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }

      const tokens = generateTokens(user.id);
      return { ...tokens, user };
    },

    refreshToken: async (_: unknown, { token }: any, ctx: Context) => {
      try {
        const decoded = jwt.verify(token, REFRESH_TOKEN_SECRET) as { userId: string };
        const user = await ctx.prisma.user.findUnique({
          where: { id: decoded.userId },
        });
        if (!user) throw new Error('User not found');

        const tokens = generateTokens(user.id);
        return { ...tokens, user };
      } catch {
        throw new GraphQLError('Invalid or expired refresh token', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
    },
  },
};

Context-Based Authentication

// Extract and verify user from the request context
interface Context {
  prisma: PrismaClient;
  currentUser: User | null;
}

async function createContext({ req }: { req: Request }): Promise<Context> {
  const prisma = new PrismaClient();
  let currentUser: User | null = null;

  const authHeader = req.headers.get('authorization');
  if (authHeader?.startsWith('Bearer ')) {
    try {
      const token = authHeader.slice(7);
      const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET) as { userId: string };
      currentUser = await prisma.user.findUnique({
        where: { id: decoded.userId },
      });
    } catch {
      // Token invalid or expired — continue as unauthenticated
    }
  }

  return { prisma, currentUser };
}

// Helper functions for resolvers
function requireAuth(ctx: Context): User {
  if (!ctx.currentUser) {
    throw new GraphQLError('You must be logged in', {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
  return ctx.currentUser;
}

function requireRole(ctx: Context, roles: string[]): User {
  const user = requireAuth(ctx);
  if (!roles.includes(user.role)) {
    throw new GraphQLError('Insufficient permissions', {
      extensions: { code: 'FORBIDDEN' },
    });
  }
  return user;
}

Directive-Based Authorization

Custom directives let you declare authorization rules directly in the schema, keeping resolvers clean:

# Schema with auth directives
directive @auth on FIELD_DEFINITION
directive @hasRole(roles: [Role!]!) on FIELD_DEFINITION

type Query {
  publicPosts: [Post!]!
  me: User! @auth
  users: [User!]! @auth @hasRole(roles: [ADMIN])
  analytics: Analytics! @auth @hasRole(roles: [ADMIN, EDITOR])
}

type Mutation {
  createPost(input: CreatePostInput!): Post! @auth
  deleteUser(id: ID!): Boolean! @auth @hasRole(roles: [ADMIN])
}

type User {
  id: ID!
  name: String!
  email: String! @auth  # Only visible to authenticated users
  role: Role! @auth @hasRole(roles: [ADMIN])
}
// Implementing auth directive transformer
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLSchema } from 'graphql';

function authDirectiveTransformer(schema: GraphQLSchema): GraphQLSchema {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
      const hasRoleDirective = getDirective(schema, fieldConfig, 'hasRole')?.[0];

      if (authDirective || hasRoleDirective) {
        const { resolve = defaultFieldResolver } = fieldConfig;

        fieldConfig.resolve = async function (source, args, context, info) {
          // Check authentication
          if (!context.currentUser) {
            throw new GraphQLError('Authentication required', {
              extensions: { code: 'UNAUTHENTICATED' },
            });
          }

          // Check role
          if (hasRoleDirective) {
            const { roles } = hasRoleDirective;
            if (!roles.includes(context.currentUser.role)) {
              throw new GraphQLError('Insufficient permissions', {
                extensions: { code: 'FORBIDDEN' },
              });
            }
          }

          return resolve(source, args, context, info);
        };
      }

      return fieldConfig;
    },
  });
}

Security Best Practices

  • Short-lived access tokens: Use 15-minute access tokens with refresh tokens for longer sessions
  • Never expose sensitive fields: Hide emails, roles, and private data from unauthorized users
  • Validate at the resolver level: Do not rely solely on directives — always check permissions in business logic
  • Rate limit auth mutations: Prevent brute-force attacks on signIn and signUp
  • Use HTTPS everywhere: Tokens in transit must be encrypted

Continue Learning