Harness-engineering graphql-auth-patterns

GraphQL Auth Patterns

install
source · Clone the upstream repo
git clone https://github.com/Intense-Visions/harness-engineering
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/Intense-Visions/harness-engineering "$T" && mkdir -p ~/.claude/skills && cp -r "$T/agents/skills/codex/graphql-auth-patterns" ~/.claude/skills/intense-visions-harness-engineering-graphql-auth-patterns-7777ee && rm -rf "$T"
manifest: agents/skills/codex/graphql-auth-patterns/SKILL.md
source content

GraphQL Auth Patterns

Implement authentication and authorization in GraphQL with context-based identity, directives, and field-level guards

When to Use

  • Adding authentication to a GraphQL API
  • Implementing role-based or permission-based access control
  • Protecting specific fields, types, or mutations
  • Choosing between directive-based and resolver-based auth
  • Preventing unauthorized data access in nested resolvers

Instructions

  1. Authenticate in the context factory, not in resolvers. Extract and verify the auth token during context creation. Every resolver then has access to
    context.currentUser
    without re-authenticating.
const server = new ApolloServer({ typeDefs, resolvers });

app.use(
  '/graphql',
  expressMiddleware(server, {
    context: async ({ req }) => {
      const token = req.headers.authorization?.replace('Bearer ', '');
      const currentUser = token ? await verifyJWT(token) : null;
      return { currentUser };
    },
  })
);
  1. Create a reusable auth guard function that resolvers call to check permissions. This keeps authorization logic in one place.
function requireAuth(context: Context): AuthenticatedUser {
  if (!context.currentUser) {
    throw new GraphQLError('Authentication required', {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
  return context.currentUser;
}

function requireRole(context: Context, role: string): AuthenticatedUser {
  const user = requireAuth(context);
  if (!user.roles.includes(role)) {
    throw new GraphQLError('Insufficient permissions', {
      extensions: { code: 'FORBIDDEN' },
    });
  }
  return user;
}
  1. Use schema directives for declarative auth. Define
    @auth
    and
    @hasRole
    directives to annotate the schema. Implement them as schema transforms.
directive @auth on FIELD_DEFINITION | OBJECT
directive @hasRole(role: String!) on FIELD_DEFINITION

type Query {
  me: User @auth
  adminDashboard: Dashboard @hasRole(role: "ADMIN")
  publicPosts: [Post!]!
}

type User @auth {
  id: ID!
  email: String!
  role: String!
}
  1. Implement directive transforms using
    @graphql-tools/utils
    .
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';

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

      if (authDirective || roleDirective) {
        const originalResolve = fieldConfig.resolve ?? defaultFieldResolver;
        fieldConfig.resolve = async (source, args, context, info) => {
          if (roleDirective) {
            requireRole(context, roleDirective.role);
          } else {
            requireAuth(context);
          }
          return originalResolve(source, args, context, info);
        };
      }
      return fieldConfig;
    },
  });
}
  1. Authorize at the data layer for defense in depth. Even with resolver-level guards, validate ownership and access in data source methods. This prevents bypasses when resolvers are added or modified.
class OrderDataSource {
  async findById(id: string, currentUser: User): Promise<Order> {
    const order = await this.db.orders.findUnique({ where: { id } });
    if (!order) throw new NotFoundError('Order');
    if (order.userId !== currentUser.id && !currentUser.roles.includes('ADMIN')) {
      throw new ForbiddenError('Not authorized to view this order');
    }
    return order;
  }
}
  1. Filter fields based on permissions when needed. Some fields should be visible only to certain roles (e.g.,
    User.email
    visible to admins and the user themselves).
const resolvers = {
  User: {
    email: (user, _args, { currentUser }) => {
      if (currentUser?.id === user.id || currentUser?.roles.includes('ADMIN')) {
        return user.email;
      }
      return null; // or throw, depending on your schema nullability
    },
  },
};
  1. Protect against query depth and complexity attacks. Authenticated users can still submit expensive queries. Use query depth limiting and cost analysis.
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(10), createComplexityLimitRule(1000)],
});
  1. Return
    UNAUTHENTICATED
    for missing credentials and
    FORBIDDEN
    for insufficient permissions.
    This distinction helps clients show the right UI (login prompt vs. access denied).

Details

Authentication vs. authorization: Authentication answers "who are you?" (JWT verification, session lookup). Authorization answers "can you do this?" (role checks, ownership validation). Keep them separate — authenticate once in context, authorize per field/mutation.

Auth approaches compared:

  • Resolver-level guards: Explicit, easy to test, but verbose in large schemas
  • Schema directives: Declarative, DRY, but requires schema transformation setup
  • Middleware (graphql-shield): Rule-based permission layer that sits between resolvers and execution. Good for complex permission matrices
  • Data-layer auth: Defense in depth — catches bypasses, but harder to return GraphQL-specific errors

graphql-shield example:

import { shield, rule, allow } from 'graphql-shield';

const isAuthenticated = rule()((parent, args, { currentUser }) => currentUser !== null);
const isAdmin = rule()((parent, args, { currentUser }) => currentUser?.roles.includes('ADMIN'));

const permissions = shield({
  Query: { '*': isAuthenticated, publicPosts: allow },
  Mutation: { deleteUser: isAdmin },
});

Common mistakes:

  • Checking auth in the parent query resolver but not in nested field resolvers (nested fields can be queried through different parents)
  • Returning different error shapes for auth failures (always use
    GraphQLError
    with standard codes)
  • Trusting client-side role claims without server-side verification
  • Not rate-limiting failed authentication attempts

Source

https://www.apollographql.com/docs/apollo-server/security/authentication/

Process

  1. Read the instructions and examples in this document.
  2. Apply the patterns to your implementation, adapting to your specific context.
  3. Verify your implementation against the details and edge cases listed above.

Harness Integration

  • Type: knowledge — this skill is a reference document, not a procedural workflow.
  • No tools or state — consumed as context by other skills and agents.
  • related_skills: graphql-resolver-pattern, graphql-apollo-server, graphql-schema-design, api-authentication-patterns, api-oauth2-flows

Success Criteria

  • The patterns described in this document are applied correctly in the implementation.
  • Edge cases and anti-patterns listed in this document are avoided.