StackPractices
advanced By Mathias Paulenko

Field-Level Auth with Custom GraphQL Directives

Add field-level authorization to GraphQL with custom schema directives. Check roles, permissions, and ownership per field in Apollo Server.

Overview

Every GraphQL field can have its own authorization rule. For example, a user reads their own email but not someone else’s, an admin sees every field, and a public field needs no auth. Custom schema directives let you declare these rules in the schema itself with @auth or @auth(requires: ADMIN), keeping authorization logic out of individual resolvers.

flowchart diagram: Client Request

When to Use

Use custom auth directives when fields inside the same type need different access levels. They also help when you want rules visible in the schema instead of buried in resolver code. If your app has roles like admin, editor, and viewer, and you need field-level granularity, directives handle that cleanly. A field that only the object owner should see? Directives cover that too, not just role-based access.

When to Avoid

Skip custom directives when your auth is coarse-grained and already lives at the API gateway or route level. They also aren’t the best choice if your rules are too complex to express declaratively, like multi-tenant org membership. Older Apollo Server versions may not support mapSchema; in that case, use a custom Apollo plugin or resolver middleware.

Solution

1. Install Dependencies

npm install @apollo/server graphql @graphql-tools/schema @graphql-tools/utils

2. Define the Schema with Directives

// schema.ts
import { gql } from 'graphql-tag';

export const typeDefs = gql`
  directive @auth(requires: Role = ADMIN) on FIELD_DEFINITION
  directive @owner on FIELD_DEFINITION

  enum Role {
    ADMIN
    EDITOR
    VIEWER
  }

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

  type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
    views: Int @auth(requires: EDITOR)
    draftContent: String @auth(requires: EDITOR) @owner
  }

  type Query {
    me: User @auth
    user(id: ID!): User @auth
    posts: [Post!]!
  }

  type Mutation {
    deletePost(id: ID!): Boolean @auth(requires: ADMIN)
  }
`;

3. Implement the Auth Directive

// directives/auth.ts
import { defaultFieldResolver, GraphQLError } from 'graphql';
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';

const roleHierarchy: Record<string, number> = {
  VIEWER: 0,
  EDITOR: 1,
  ADMIN: 2,
};

function hasRole(userRole: string, requiredRole: string): boolean {
  return (roleHierarchy[userRole] ?? -1) >= (roleHierarchy[requiredRole] ?? 999);
}

export function authDirectiveTransformer(schema: any) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
      if (!authDirective) return fieldConfig;

      const { requires } = authDirective;
      const { resolve = defaultFieldResolver } = fieldConfig;

      fieldConfig.resolve = async (source, args, context, info) => {
        if (!context.user) {
          throw new GraphQLError('Authentication required', {
            extensions: { code: 'FORBIDDEN' },
          });
        }

        if (requires && !hasRole(context.user.role, requires)) {
          throw new GraphQLError(`Requires role: ${requires}`, {
            extensions: { code: 'FORBIDDEN' },
          });
        }

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

      return fieldConfig;
    },
  });
}

4. Implement the Owner Directive

// directives/owner.ts
import { defaultFieldResolver, GraphQLError } from 'graphql';
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';

export function ownerDirectiveTransformer(schema: any) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const ownerDirective = getDirective(schema, fieldConfig, 'owner')?.[0];
      if (!ownerDirective) return fieldConfig;

      const { resolve = defaultFieldResolver } = fieldConfig;

      fieldConfig.resolve = async (source, args, context, info) => {
        if (!context.user) {
          throw new GraphQLError('Authentication required', {
            extensions: { code: 'FORBIDDEN' },
          });
        }

        if (source.authorId && source.authorId !== context.user.id) {
          if (context.user.role !== 'ADMIN') {
            throw new GraphQLError('You can only access your own data', {
              extensions: { code: 'FORBIDDEN' },
            });
          }
        }

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

      return fieldConfig;
    },
  });
}

5. Register the Directives in Apollo Server

// server.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { authDirectiveTransformer } from './directives/auth';
import { ownerDirectiveTransformer } from './directives/owner';
import { typeDefs, resolvers } from './schema';

let schema = makeExecutableSchema({ typeDefs, resolvers });
schema = authDirectiveTransformer(schema);
schema = ownerDirectiveTransformer(schema);

const server = new ApolloServer({ schema });

startStandaloneServer(server, {
  context: async ({ req }) => ({
    user: req.headers.authorization ? { id: '1', role: 'EDITOR' } : null,
  }),
}).then(({ url }) => console.log(`Server ready at ${url}`));

Explanation

@auth wraps the field resolver. Before the original resolver runs, it checks context.user and the required role. Role hierarchy uses a numeric mapping so ADMIN (2) satisfies EDITOR (1) and VIEWER (0). I picked numbers over string comparison because it handles implicit hierarchy in one line. No need to list every role an admin should inherit — the numeric comparison does it for you.

mapSchema from @graphql-tools/utils walks every field definition and applies the transformer wherever the directive appears. The original resolve function is preserved, or defaultFieldResolver is used when the field has no custom resolver. This matters more than it sounds: if you forget the fallback, fields without a custom resolver silently return undefined and you’ll spend an hour wondering why User.bio comes back empty.

@owner checks the parent object’s authorId against context.user.id. That adds ownership-based authorization that role checks alone can’t handle. Admins skip the ownership check on purpose — support staff need to access user data in tickets. If you want to block admins too, drop the context.user.role !== 'ADMIN' branch and let the ownership check apply universally.

One thing that bit me in production: directive transformers run once at schema build time, not per request. That means the context object you reference inside the wrapped resolver is the live request context, not a build-time snapshot. If you need per-request configuration (like a dynamic role source), read it from context at runtime, never from closure scope.

Variants

Role-based auth with @auth is the most common starting point. For finer control, @hasPermission checks specific actions like post:delete instead of broad roles. The @owner directive adds ownership checks, and stacked directives like @auth(requires: EDITOR) @owner require both. You can also use directives for rate limiting or query depth guards with @cost.

Permission-Based Directives

Instead of roles, check specific permissions:

export function permissionDirectiveTransformer(schema: any) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const directive = getDirective(schema, fieldConfig, 'hasPermission')?.[0];
      if (!directive) return fieldConfig;

      const { permission } = directive;
      const { resolve = defaultFieldResolver } = fieldConfig;

      fieldConfig.resolve = async (source, args, context, info) => {
        if (!context.user?.permissions?.includes(permission)) {
          throw new GraphQLError(`Missing permission: ${permission}`, {
            extensions: { code: 'FORBIDDEN' },
          });
        }
        return resolve(source, args, context, info);
      };

      return fieldConfig;
    },
  });
}

Combined Auth and Owner

Stack directives for layered checks:

type Post {
  id: ID!
  title: String!
  draftContent: String @auth(requires: EDITOR) @owner
}

The field requires at least EDITOR and ownership.

Query Complexity Guard

You can also use a directive to limit query cost:

directive @cost(complexity: Int!) on FIELD_DEFINITION

type Query {
  users: [User!]! @cost(complexity: 10)
  allPosts: [Post!]! @cost(complexity: 50)
}

This gives you a basic cost guard without touching resolvers.

Best Practices

Declare auth in the schema so rules stay visible and easy to audit. See the complete guide to GraphQL security for more on GraphQL auth. Use role hierarchy so ADMIN satisfies EDITOR and VIEWER requirements. Combine role checks with ownership checks, because role-based auth alone isn’t enough for user-specific data.

Test different roles by sending queries with different context.user values and asserting who can see what. Cache permission lookups so repeated field checks don’t hit the database on every resolver. In production, return generic FORBIDDEN messages; detailed error text can leak schema information. A verbose error once exposed an internalNotes field on a user type — the field name itself was enough to tell attackers what to look for.

Common Mistakes

Only protecting Query and Mutation fields is a common miss. Nested fields like user.email need directives too. Don’t forget defaultFieldResolver; if a field doesn’t have a custom resolver, the directive must call it.

Checking auth in both resolvers and directives creates duplicated logic, so pick one approach. Always handle context.user being null and throw before you touch user.role. Applying directives only on list fields is risky; individual list items can still expose sensitive data if the list isn’t filtered server-side.

Don’t skip unauthenticated requests. Public fields should still work without a token, or your landing page and public queries will break. When stacking directives, document the evaluation order so your team doesn’t get surprised by a short-circuit.

Summary

Custom schema directives move authorization from resolver code into the schema itself. @auth checks roles with a numeric hierarchy so ADMIN satisfies EDITOR and VIEWER. @owner adds ownership checks that roles alone can’t express. Stack them when a field needs both. Use mapSchema to walk field definitions and wrap resolvers wherever a directive appears. Keep public fields working without a token, return generic FORBIDDEN errors in production, and test with different context.user values to catch leaks before they reach users.

See Also

The Apollo Server directives documentation covers built-in and custom directive usage. For the underlying transformer API, the @graphql-tools/schema docs explain mapSchema and getDirective in depth. The GraphQL spec on directives defines the syntax and execution semantics. For a broader look at GraphQL auth patterns including OAuth and JWT integration, the JWT authentication recipe on this site covers token-based auth flows. If you’re validating input before auth runs, the GraphQL input validation recipe pairs well with directive-based auth.

Frequently Asked Questions

Should I use directives or resolver-level auth?

Directives work well for straightforward field-level rules. Switch to resolver-level auth when the logic gets complex — say, allowing an edit only for the owner or org members.

Can I use multiple directives on one field?

Yes. Directives stack, and each one wraps the previous resolver. Order matters, because the outer directives run first.

Do directives work with Apollo Federation?

They work, but each subgraph has to implement the directive on its own. The gateway won't re-run subgraph directives.

How do I test field-level auth?

Send queries with different user tokens and check that protected fields return errors or null based on the role. A tiny helper that swaps context.user for each role and permission makes this much faster.

How do I combine role-based auth with ownership checks?

Stack the directives: @auth(requires: EDITOR) @owner. The @auth directive checks the role first. If that passes, @owner verifies the user owns the resource. If either fails, the field errors out or returns null — your call on which behavior to use.