Skip to main content
Technology & EngineeringGraphql334 lines

Authentication

Authentication and authorization patterns for securing GraphQL APIs

Quick Summary24 lines
You are an expert in securing GraphQL APIs, helping developers implement authentication, authorization, and access control using JWTs, middleware, directives, and field-level permissions.

## Key Points

1. **Resolver-level checks** — inline authorization in each resolver.
2. **Middleware/wrapper functions** — reusable authorization logic that wraps resolvers.
3. **Schema directives** — declarative authorization in the schema via custom directives.
1. **Authenticate at the transport layer, authorize in resolvers** — verify tokens in the context function; check permissions in resolvers or directives.
2. **Never trust the client** — always validate permissions server-side. Hiding UI elements is not a security measure.
3. **Use short-lived access tokens with refresh tokens** — access tokens should expire in 15 minutes or less, with a longer-lived refresh token for renewal.
4. **Apply defense in depth** — combine resolver-level checks with query complexity limits and rate limiting.
5. **Log authorization failures** — track denied access attempts for security monitoring and incident response.
- **Storing secrets in JWTs** — JWTs are base64-encoded, not encrypted. Never put sensitive data in the payload.
- **Not invalidating tokens on logout** — JWTs are stateless, so you need a blocklist or short expiry to handle logout and compromised tokens.
- **Single authorization check for complex operations** — a user may be authorized to read a post but not its private analytics. Check permissions per field, not per query.
- **Leaking information through error messages** — "User not found" vs "Not authorized" tells an attacker whether the resource exists. Use consistent error messages.

## Quick Example

```
Client → HTTP Header (Bearer token) → Context Function → Token Verification → User in Context → Resolvers
```
skilldb get graphql-skills/AuthenticationFull skill: 334 lines
Paste into your CLAUDE.md or agent config

Authentication — GraphQL

You are an expert in securing GraphQL APIs, helping developers implement authentication, authorization, and access control using JWTs, middleware, directives, and field-level permissions.

Overview

Authentication (who is the user?) and authorization (what can they do?) are critical in GraphQL APIs. Unlike REST, where endpoints map to resources, GraphQL exposes a single endpoint with a flexible query language — making access control both more important and more nuanced. Authorization must be applied at the field and resolver level, not at the route level.

Core Concepts

Authentication Flow

Authentication typically happens at the transport layer, before GraphQL execution begins:

Client → HTTP Header (Bearer token) → Context Function → Token Verification → User in Context → Resolvers
// Context function extracts and verifies the user
async function buildContext({ req }: { req: express.Request }): Promise<Context> {
  const token = req.headers.authorization?.replace("Bearer ", "");

  if (!token) {
    return { currentUser: null };
  }

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
    const user = await userLoader.load(payload.sub);
    return { currentUser: user };
  } catch (err) {
    // Invalid or expired token — treat as unauthenticated
    return { currentUser: null };
  }
}

Authorization Strategies

There are three main approaches:

  1. Resolver-level checks — inline authorization in each resolver.
  2. Middleware/wrapper functions — reusable authorization logic that wraps resolvers.
  3. Schema directives — declarative authorization in the schema via custom directives.

Implementation Patterns

Resolver-Level Authorization

The simplest approach, suitable for small APIs:

const resolvers = {
  Query: {
    me: (_, __, { currentUser }) => {
      if (!currentUser) throw new GraphQLError("Not authenticated", {
        extensions: { code: "UNAUTHENTICATED" },
      });
      return currentUser;
    },

    adminDashboard: (_, __, { currentUser }) => {
      if (!currentUser) throw new GraphQLError("Not authenticated", {
        extensions: { code: "UNAUTHENTICATED" },
      });
      if (!currentUser.roles.includes("ADMIN")) {
        throw new GraphQLError("Not authorized", {
          extensions: { code: "FORBIDDEN" },
        });
      }
      return getDashboardData();
    },
  },
};

Reusable Auth Wrappers

Extract authorization into composable higher-order functions:

function requireAuth<TParent, TArgs>(
  resolver: (parent: TParent, args: TArgs, context: AuthenticatedContext, info: GraphQLResolveInfo) => any
) {
  return (parent: TParent, args: TArgs, context: Context, info: GraphQLResolveInfo) => {
    if (!context.currentUser) {
      throw new GraphQLError("Authentication required", {
        extensions: { code: "UNAUTHENTICATED" },
      });
    }
    return resolver(parent, args, context as AuthenticatedContext, info);
  };
}

function requireRole<TParent, TArgs>(role: string, resolver: Function) {
  return requireAuth((parent: TParent, args: TArgs, context: AuthenticatedContext, info) => {
    if (!context.currentUser.roles.includes(role)) {
      throw new GraphQLError(`Role "${role}" required`, {
        extensions: { code: "FORBIDDEN" },
      });
    }
    return resolver(parent, args, context, info);
  });
}

// Usage
const resolvers = {
  Query: {
    me: requireAuth((_, __, { currentUser }) => currentUser),
    adminDashboard: requireRole("ADMIN", () => getDashboardData()),
    users: requireRole("ADMIN", (_, args, { dataSources }) => dataSources.users.list(args)),
  },
  Mutation: {
    updateProfile: requireAuth((_, { input }, { currentUser, dataSources }) =>
      dataSources.users.update(currentUser.id, input)
    ),
  },
};

Schema Directives for Authorization

Declare authorization requirements in the schema itself:

directive @auth(requires: Role = USER) on FIELD_DEFINITION | OBJECT

enum Role {
  ADMIN
  MODERATOR
  USER
}

type Query {
  me: User @auth
  users: [User!]! @auth(requires: ADMIN)
  publicPosts: [Post!]!
}

type User @auth {
  id: ID!
  email: String! @auth(requires: ADMIN)
  displayName: String!
  role: Role! @auth(requires: ADMIN)
}

Implement the directive transformer:

import { mapSchema, getDirective, MapperKind } from "@graphql-tools/utils";

function authDirectiveTransformer(schema: GraphQLSchema): GraphQLSchema {
  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 = (parent, args, context, info) => {
        if (!context.currentUser) {
          throw new GraphQLError("Authentication required", {
            extensions: { code: "UNAUTHENTICATED" },
          });
        }

        const roleHierarchy = ["USER", "MODERATOR", "ADMIN"];
        const userRoleIndex = roleHierarchy.indexOf(context.currentUser.role);
        const requiredRoleIndex = roleHierarchy.indexOf(requiredRole);

        if (userRoleIndex < requiredRoleIndex) {
          throw new GraphQLError("Insufficient permissions", {
            extensions: { code: "FORBIDDEN" },
          });
        }

        return originalResolve(parent, args, context, info);
      };

      return fieldConfig;
    },
  });
}

Field-Level Visibility

Hide sensitive fields based on the viewer's relationship to the data:

const resolvers = {
  User: {
    email: (user, _, { currentUser }) => {
      // Users can see their own email; admins can see all emails
      if (currentUser?.id === user.id || currentUser?.role === "ADMIN") {
        return user.email;
      }
      return null;
    },

    phoneNumber: (user, _, { currentUser }) => {
      if (currentUser?.id === user.id) return user.phoneNumber;
      return null;
    },
  },
};

Token Refresh Pattern

Handle expired tokens gracefully on the client side:

import { fromPromise } from "@apollo/client";
import { onError } from "@apollo/client/link/error";

let isRefreshing = false;
let pendingRequests: (() => void)[] = [];

const errorLink = onError(({ graphQLErrors, operation, forward }) => {
  const unauthError = graphQLErrors?.find(
    (e) => e.extensions?.code === "UNAUTHENTICATED"
  );

  if (!unauthError) return;

  if (!isRefreshing) {
    isRefreshing = true;

    return fromPromise(
      refreshAccessToken()
        .then((newToken) => {
          // Retry pending requests
          pendingRequests.forEach((cb) => cb());
          pendingRequests = [];
          return newToken;
        })
        .catch(() => {
          pendingRequests = [];
          logout();
          return null;
        })
        .finally(() => {
          isRefreshing = false;
        })
    ).flatMap((newToken) => {
      if (!newToken) return forward(operation); // Will fail, triggers logout

      operation.setContext(({ headers = {} }) => ({
        headers: { ...headers, authorization: `Bearer ${newToken}` },
      }));

      return forward(operation);
    });
  }

  // Queue subsequent requests while refreshing
  return fromPromise(
    new Promise<void>((resolve) => pendingRequests.push(resolve))
  ).flatMap(() => forward(operation));
});

Rate Limiting by User

Apply per-user rate limits to prevent abuse:

import { RateLimiterMemory } from "rate-limiter-flexible";

const rateLimiter = new RateLimiterMemory({
  points: 100,  // 100 requests
  duration: 60, // per 60 seconds
});

const rateLimitPlugin: ApolloServerPlugin<Context> = {
  async requestDidStart({ contextValue }) {
    const key = contextValue.currentUser?.id ?? "anonymous";
    try {
      await rateLimiter.consume(key);
    } catch {
      throw new GraphQLError("Rate limit exceeded", {
        extensions: { code: "RATE_LIMITED" },
      });
    }
  },
};

Core Philosophy

GraphQL security requires a fundamentally different mindset than REST security. In REST, each endpoint is a natural authorization boundary — you protect /admin/users differently from /public/posts. In GraphQL, every client talks to a single endpoint and can request arbitrary combinations of fields and relationships. This means authorization must be pushed down to the field and resolver level, not applied at the route level. The schema is your API surface, and every node in the graph is a potential access control decision.

The strongest authentication architectures separate identity verification from permission evaluation. Authentication (who are you?) happens once at the transport layer — the context function verifies the JWT, decodes the claims, and attaches the user to context. Authorization (what can you do?) happens repeatedly throughout query execution — each resolver or directive checks whether the authenticated user has the right to access that specific piece of data. This separation keeps the concerns clean and makes each layer independently testable.

Defense in depth is not optional. A single authorization check at the query root is insufficient because GraphQL's nested nature means sensitive data can be reached through multiple paths. Combine field-level permission checks with query complexity limits, rate limiting per user, and short-lived tokens with refresh rotation. No single layer is bulletproof, but together they create a security posture that is resilient to both accidental misuse and deliberate attack.

Anti-Patterns

  • Route-level-only authentication — protecting the /graphql endpoint with a middleware check and assuming all resolvers are therefore safe ignores the fact that different fields and mutations require different permission levels. A logged-in user should not automatically access admin data.

  • Trusting client-side UI hiding as security — removing a button or hiding a form field on the client is not access control. Any user with a network inspector can craft arbitrary GraphQL operations. Authorization must always be enforced server-side.

  • Storing sensitive data in JWT payloads — JWTs are base64-encoded, not encrypted. Embedding secrets, internal IDs, or PII in the token payload exposes them to anyone who intercepts or inspects the token.

  • One-size-fits-all error messages for auth failures — returning the same vague "unauthorized" message for both missing authentication and insufficient permissions makes debugging impossible for legitimate users and provides no useful signal for monitoring systems.

  • Long-lived access tokens without refresh rotation — access tokens that last hours or days widen the window of exploitation if compromised. Use short-lived access tokens (15 minutes or less) paired with refresh tokens, and rotate refresh tokens on each use to detect theft.

Best Practices

  1. Authenticate at the transport layer, authorize in resolvers — verify tokens in the context function; check permissions in resolvers or directives.
  2. Never trust the client — always validate permissions server-side. Hiding UI elements is not a security measure.
  3. Use short-lived access tokens with refresh tokens — access tokens should expire in 15 minutes or less, with a longer-lived refresh token for renewal.
  4. Apply defense in depth — combine resolver-level checks with query complexity limits and rate limiting.
  5. Log authorization failures — track denied access attempts for security monitoring and incident response.
  6. Return null for unauthorized optional fields, throw for unauthorized required operations — failing silently for optional data is better UX; mutations and required data should throw clear errors.

Common Pitfalls

  • Authorizing only at the Query level — nested resolvers like User.email also need access control. A user querying their own profile should see their email, but querying another user's profile should not.
  • Storing secrets in JWTs — JWTs are base64-encoded, not encrypted. Never put sensitive data in the payload.
  • Not invalidating tokens on logout — JWTs are stateless, so you need a blocklist or short expiry to handle logout and compromised tokens.
  • Single authorization check for complex operations — a user may be authorized to read a post but not its private analytics. Check permissions per field, not per query.
  • Leaking information through error messages — "User not found" vs "Not authorized" tells an attacker whether the resource exists. Use consistent error messages.

Install this skill directly: skilldb add graphql-skills

Get CLI access →