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.
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.
Related Resources
Build a GraphQL API with Apollo Server and TypeScript
How to build a production-ready GraphQL API using Apollo Server, TypeScript, and DataLoader to solve the N+1 query problem
RecipeStructured GraphQL Errors with Extension Codes
Implement structured error handling in GraphQL with custom error classes, extension codes, and consistent error formatting for clients
RecipeJWT Authentication
How to generate, validate, and refresh JSON Web Tokens for stateless API authentication.
RecipeSecure API Gateway with Custom Lambda Authorizers
Implement custom Lambda authorizers for API Gateway with JWT validation, IAM policy generation, and caching for token-based authentication in serverless APIs.
RecipeValidate and Sanitize GraphQL Input Types Server-Side
Implement centralized input validation in GraphQL using custom validation functions, Zod schemas, and input type transforms
GuideComplete Guide to GraphQL Security
Secure GraphQL APIs against introspection leaks, query depth attacks, cost-based DoS, batching abuse, and injection. Covers auth patterns, rate limiting, and production hardening.