Directives That Actually Enforce Something
Directives That Actually Enforce Something
Every GraphQL developer has used the built-in directives: @skip and @include change what gets selected, @deprecated annotates a field. All three are interpreted by the executor itself, which is why they work everywhere without configuration.
Custom directives extend the same syntax to rules the schema cares about but the specification does not:
directive @auth(requires: Role = USER!) on FIELD_DEFINITION | OBJECT
directive @rateLimit(max: Int!, window: String!) on FIELD_DEFINITION
directive @constraint(minInt: Int, maxInt: Int) on INPUT_FIELD_DEFINITION
directive @sql(column: String!) on FIELD_DEFINITION
Three parts of that block do real work:
- The locations list is a permission list.
on FIELD_DEFINITION | OBJECTallows the directive on a field or a whole type and nothing else. Put@authon an operation type and validation fails with "Directive @auth may not be used on QUERY" — the mistake is caught when the schema builds, not in review three days later. - Arguments are typed.
max: Int!means@rateLimit(max: "five")fails validation exactly like a bad field argument does, before anything runs. - The name is the contract. Tooling, code generators, and other teams read these names; renaming a directive that appears across modules is a schema change like any other.
What they are for
The payoff is one declaration both humans and machines read:
type Query {
"Anyone signed in."
me: User @auth(requires: USER)
"Admins only."
auditLog(range: DateRange!): [AuditEntry!]! @auth(requires: ADMIN)
}
Without directives, those two rules live in middleware, resolver wrappers, and a wiki page — three places that drift apart until the wiki and the code disagree about who can read the audit log. With them, an introspection query answers "which fields require admin?" in one round trip, and a reviewer sees the requirement next to the field it protects.
Directives also read well in generated documentation, which changes review behavior: reviewers debate the rule itself instead of hunting through middleware to discover the rule exists.
The catch is one sentence long: declaring a rule and enforcing it are different steps. That gap is the subject of the next chapter.
Where a Directive May Appear, and Where It Runs
Where a Directive May Appear, and Where It Runs
A directive in SDL is metadata. Unless something on the server executes it, @auth guarantees nothing — introspection will cheerfully report the rule while every resolver stays open. Server frameworks provide a schema transformation hook that wraps resolvers at startup:
const { mapSchema, getDirective, MapperKind } = require('@graphql-tools/utils');
const { defaultFieldResolver, GraphQLError } = require('graphql');
function authDirectiveTransformer(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const matches = getDirective(schema, fieldConfig, 'auth');
if (!matches || matches.length === 0) return fieldConfig;
const { requires } = matches[0].args;
const { resolve = defaultFieldResolver } = fieldConfig;
return {
...fieldConfig,
resolve: (root, args, ctx, info) => {
if (!ctx.role || !hasRole(ctx.role, requires)) {
throw new GraphQLError('FORBIDDEN', {
extensions: { code: 'FORBIDDEN', required: requires },
});
}
return resolve(root, args, ctx, info);
},
};
},
});
}
export const schema = authDirectiveTransformer(makeExecutableSchema({ typeDefs, resolvers }));
Read the shape: the transformer walks fields, finds the directive, and replaces resolve with a wrapper that checks ctx.role before delegating to the original. Field-level directives wrap one field; an OBJECT-level directive must be applied to each of that type's fields, which is why object-wide auth is a second loop in most implementations rather than a one-line reuse.
Placement decides the layer
| Location | Typical enforcement point |
|---|---|
FIELD_DEFINITION |
resolver wrapper, as above |
OBJECT |
applied to every field of the type |
INPUT_FIELD_DEFINITION |
validation before the resolver runs |
ARGUMENT_DEFINITION |
argument coercion step |
UNION / INTERFACE |
rarely executed; consumed by tooling |
Order matters when directives stack. @auth then @rateLimit should check the role before spending rate budget, so compose the wrappers outermost-first in that order — an inverted stack spends a paid rate slot on a request that was always going to be rejected.
Two cautions from production. Directive execution happens in the GraphQL layer, so anything reaching the resolver without passing through it — an internal job runner, a direct function call — gets no protection; keep an authorization check in the service as defense in depth. And building the schema with transformers must happen once at startup, not per request, or you pay a schema walk on every operation.
Directive-Driven Tooling and the Decorations Trap
Directive-Driven Tooling and the Decorations Trap
Directives earn their keep twice: once when a server executes them, and once when tooling merely reads them.
type Product {
id: ID!
"Maps to products.sku in the warehouse database."
sku: String! @sql(column: "sku")
price: Money! @auth(requires: SUPPORT)
createdAt: DateTime! @timestamp(mode: CREATED_AT)
}
A code generator walks those annotations and emits what would otherwise be handwritten: an ORM mapping from @sql, a typed authorization hook from @auth, a client-side formatter from @timestamp. Federation's @key(fields: "id") is the same idea at scale — a directive whose only consumer is the composition engine — and @external, @shareable, and friends do the work of a paragraph of documentation each. Docs sites read @example to render samples without maintaining a second source of truth.
The trap
A schema full of @auth and @constraint looks locked down. If no transformer executes them, it is decoration: the rule shows up in introspection, the review approves it, and the resolver still runs unguarded — an attacker reading the schema learns exactly which rules are imaginary. Two habits prevent it:
- Every custom directive gets an owner and a test. The test asserts that a request violating the rule is rejected — not that the directive appears in SDL. Presence is easy to verify and proves nothing.
- Limit the vocabulary. Five directives that all run beat twenty that only describe, because each new directive is API surface: renaming it or changing its arguments affects every module that imports its definition.
Repeatable and composed directives
A directive declared repeatable may stack on one element (@tag(name: "public") @tag(name: "beta")), which is useful for labels but means order and duplicates become meaningful. Directive definitions must be unique: declaring @auth in two composed modules with different argument types fails composition outright rather than picking a winner — exactly the collision case schema composition guidelines exists to catch before merge.
Interview framing: name the two halves — declaration with locations, execution via a schema transformer — give one example of tooling that reads a directive without executing it, and say why a decorative directive is worse than having none at all.
Practice Problems
Declare a directive @auth(requires: Role = USER!) that may decorate object fields and whole object types, then implement the schema transformer that enforces it: a field-level directive guards that field, an object-level directive guards every field of the type, and a rejected request returns a GraphQL error with an extension code. Include a test that proves the rule actually rejects.
Example:
Input: type Query { auditLog: [AuditEntry!]! @auth(requires: ADMIN) } with ctx.role = "USER"
Output: Execution error: FORBIDDEN, extensions.required = ADMIN; auditLog never resolves
The wrapper runs before delegating to the original resolver, so the protected resolver body is unreachable for an under-privileged caller.
Optimal Solution — O(1) per decorated field per request time, O(d) wrappers for d decorated fields space
Declare with an explicit locations list, wrap resolve for decorated fields during a one-time startup transformation, apply object-level directives across the type fields, and cover both with a test that sends a violating request.
directive @auth(requires: Role = USER!) on FIELD_DEFINITION | OBJECT
enum Role { USER SUPPORT ADMIN }
function authDirectiveTransformer(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const args = getDirectiveArgs(schema, fieldConfig, 'auth');
if (!args) return fieldConfig;
const { resolve = defaultFieldResolver } = fieldConfig;
return {
...fieldConfig,
resolve: (root, opArgs, ctx, info) => {
if (!ctx.role || rank(ctx.role) < rank(args.requires)) {
throw new GraphQLError('FORBIDDEN', {
extensions: { code: 'FORBIDDEN', required: args.requires },
});
}
return resolve(root, opArgs, ctx, info);
},
};
},
});
}
// test
const res = await execute({
schema,
document: gql`query { auditLog { id } }`,
contextValue: { role: 'USER' },
});
expect(res.errors[0].extensions.code).toBe('FORBIDDEN'); Edge Cases:
- Directive placed on an operation type — must fail schema validation
- Both a field-level and an object-level directive on the same field
- Missing ctx.role entirely, which must deny rather than default to admin
- Two modules declaring @auth with different argument types
Quiz
1. What does the locations list on a custom directive actually do?
2. A schema declares @auth on many fields but no transformer processes it. What is the actual security posture?
3. Why should @auth wrap outside @rateLimit when both decorate the same field?
Flashcards
Question
Custom directive declaration anatomy
Click to reveal answer
Answer
directive @name(arg: Type = default) on LOCATION | LOCATION — locations restrict legal placement, arguments are typed and validated like field arguments.
Question
What enforces a custom directive
Click to reveal answer
Answer
Nothing by default. A server-side schema transformer (mapSchema/getDirective) wraps resolvers at startup; without it the directive is metadata.
Question
Directive location categories
Click to reveal answer
Answer
FIELD_DEFINITION and OBJECT run as resolver wrappers; INPUT_FIELD_DEFINITION and ARGUMENT_DEFINITION run in validation; UNION/INTERFACE feed tooling.
Question
Directives that only inform tooling
Click to reveal answer
Answer
Codegen and composition read them without executing: @sql, @timestamp, federation @key/@external/@shareable, docs @example.
Question
Rule that keeps directives honest
Click to reveal answer
Answer
Every directive has an owner and a test proving a violating request is rejected; presence in SDL proves nothing. Keep the vocabulary small.
Revision Notes
Key Takeaways
- 1. Locations and typed arguments turn a naming convention into schema-build-time validation
- 2. Declaration without execution is decoration — and decoration advertises controls that do not exist
- 3. Enforcement lives in a schema transformer that wraps resolve once at startup
- 4. Stack ordering is a cost decision: authenticate before spending rate budget
Interview Tips
- • Give the full path: SDL declaration, validation of placement, transformer wrapping resolve at startup, then the request hitting the wrapper
- • Distinguish directives that execute from directives that only feed codegen or composition — interviewers probe whether you know the difference
- • Defend a directive count: what each one enforces, who owns it, and which test proves it works
Cheat Sheet
Custom Directives
Declare
directive @auth(requires: Role = USER!) on FIELD_DEFINITION | OBJECT
directive @rateLimit(max: Int!, window: String!) on FIELD_DEFINITION
directive @constraint(minInt: Int, maxInt: Int) on INPUT_FIELD_DEFINITION
Enforce (schema transformer at startup)
mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (cfg) => {
const d = getDirective(schema, cfg, 'auth')[0];
if (!d) return cfg;
const { resolve = defaultFieldResolver } = cfg;
return { ...cfg, resolve: (r, a, ctx, info) => {
if (!hasRole(ctx.role, d.args.requires)) throw forbidden(d.args.requires);
return resolve(r, a, ctx, info);
} };
},
});
Locations: FIELD_DEFINITION/OBJECT = resolver wrapper | INPUT_FIELD_DEFINITION/ARGUMENT_DEFINITION = validation | UNION/INTERFACE = tooling only.
Rules: locations restrict placement at build time; decorator-only directives are unenforced; stack auth outside rateLimit; one owner plus one rejection test per directive; definitions must be unique across composed modules.