Skip to content
advanced Phase 10 · Authentication and Authorization for GraphQL APIs

Authorization Directives in the Schema

Attaching auth or role directives to fields so access rules live with the schema.

50m
1 problems
Topic Progress 0%

Writing the Rule Into the Schema

Writing the Rule Into the Schema

Scattered requireRole calls work, but the rule is invisible until you open a resolver file. Attached to SDL, the rule travels with the field — readable in the schema, diffable in a pull request, and impossible to overlook when someone adds a sensitive field next week:

enum Role { USER SUPPORT ADMIN }

directive @hasRole(role: Role!) on FIELD_DEFINITION
directive @public on FIELD_DEFINITION

type Query {
  posts: [Post!]! @public
  me: User @public
  auditLog: [AuditEntry!]! @hasRole(role: ADMIN)
  systemConfig: SystemConfig! @hasRole(role: SUPPORT)
}

A reviewer reading auditLog now knows its policy without leaving the schema. Change the policy from ADMIN to SUPPORT and the diff shows a permission change, which is exactly what a security reviewer wants to see in review.

The schema also becomes lintable: a rule that rejects any Query field carrying neither @public nor @hasRole makes "deny by default" mechanical instead of aspirational. That lint belongs in CI alongside schema checks — see schema linting rules for where such rules live.

Two design choices to settle up front: what happens to a field with no directive (no wrapper is generated, so it is unprotected — make the lint forbid that state), and whether the directive is documentation only or enforced by the server. Documentation-only directives are how teams end up with a beautiful annotated schema and an open admin endpoint. Enforce it.

One nuance lives in the on clause. FIELD_DEFINITION marks the fields the server resolves, which is where enforcement happens. A directive placed at query position (FIELD) is seen only by client tooling — useful for a generator that hides buttons — but it travels with the document, so a client that removes it loses nothing it needed. Listing both makes the split explicit:

directive @hasRole(role: Role!) on FIELD_DEFINITION | FIELD

The server copy enforces; the query-position copy informs. A policy change then reads in review as @hasRole(role: ADMIN) → @hasRole(role: SUPPORT) on exactly the line a security reviewer needs to see.

How the Directive Wraps the Resolver

How the Directive Wraps the Resolver

SDL directives ship as metadata; something must read that metadata and act. With graphql-tools, mapSchema walks the fields, getDirective reads the arguments, and the transform replaces the resolver with a guarded one:

import { mapSchema, getDirective, MapperKind, defaultFieldResolver } from '@graphql-tools/schema';

export function hasRoleTransformer(schema) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const applied = getDirective(schema, fieldConfig, 'hasRole')?.[0];
      if (!applied) return fieldConfig;                  // no directive: no wrapper

      const required = applied.role;
      const resolve = fieldConfig.resolve ?? defaultFieldResolver;

      return {
        ...fieldConfig,
        resolve: (root, args, ctx, info) => {
          const roles = ctx.viewer?.roles ?? [];
          if (!roles.includes(required)) {
            throw new ForbiddenError('Insufficient role', {
              extensions: { code: 'FORBIDDEN', requiredRole: required },
            });
          }
          return resolve(root, args, ctx, info);          // original resolver runs after
        },
      };
    },
  });
}

const schema = hasRoleTransformer(makeExecutableSchema({ typeDefs, resolvers }));

Non-obvious details worth understanding:

  • The wrapper runs during field execution, so the check happens per requested field, after context exists and before any data is read. Roles come from verified claims — see request auth context.
  • ?? defaultFieldResolver keeps the directive working for fields that never declared a resolver; without it, root-field lookups would silently bypass the guard.
  • Transformer order matters. Stacked transforms compose, so the outermost wrapper runs first. Put authorization last in the chain of wrappers — meaning applied so its check is outermost — and a field-stripping or pagination transform cannot run before the permission decision.
  • Reuse with parameters. @hasRole(role: ADMIN) and @auth(requires: EDITOR) are the same function with different arguments; one directive with an enum argument beats five near-identical directives.

Where Directive Checks Stop Short

Where Directive Checks Stop Short

Directive-based authorization is field-shaped. It answers "may this caller read this field at all?" and stops there — four gaps remain, each owned by something else.

Object ownership. @hasRole(role: USER) lets any user read Query.invoice(id). Whether this invoice belongs to that user is a post-fetch decision that needs the row in hand:

invoice: async (_, { id }, ctx) => {
  const row = await invoiceForTenant(id, ctx.viewer.tenantId);
  return row;                                  // null when it is not theirs
};

The directive gets you to the resolver; the resolver still scopes. That object-level work is tenant data scoping.

Misconfiguration and coverage. An unprotected field has no wrapper — absence of a directive means absence of a check, and nothing in the type system objects. Directives must be enforced by tests, not trust:

it('denies auditLog to a plain user', async () => {
  const res = await execute({ query: '{ auditLog { id } }', contextValue: ctxAs('user') });
  expect(res.errors?.[0].extensions.code).toBe('FORBIDDEN');
});

Add a schema-level sweep asserting every sensitive field carries a directive, because a code review can miss one field in a 400-field schema.

Resolution paths that skip transforms. Directives live in your schema construction. A subgraph behind a federation gateway enforces its own copy, a resolver invoked in a unit test never sees the wrapper, and a code path that calls the service layer directly ignores the schema entirely. Enforcement must hold at the layer that touches data.

Client-side value is convenience, not security. Because the rules are in SDL, code generation can emit typed guards so the UI stops offering buttons that would fail — see TypeScript codegen. That improves the experience; the server check remains the only control.

Practice Problems

0 / 1 solved
Write the Directive and Its Guard
Schema Directive AuthZ

Declare a @requiresRole directive in SDL for field definitions, then implement the schema transform that wraps every marked field so execution denies callers whose verified roles do not include the required role. Fields without the directive must be rejected by a schema lint rule you also specify.

Example:

Input: SDL: systemConfig: SystemConfig! @requiresRole(role: ADMIN) | context: { viewer: { roles: ["user"] } }

Output: Field execution throws with extensions.code = FORBIDDEN; the resolver body never runs

getDirective reads the ADMIN argument off the field config, the wrapper compares it against ctx.viewer.roles before delegating, and the guard throws — so no data access happens for a caller without the role.

Optimal Solution — O(F) to transform and lint, O(1) per field execution time, O(F) space

Define the directive in SDL, wrap marked fields with mapSchema during schema construction, and enforce directive coverage with a validation rule over the built schema

// SDL
directive @requiresRole(role: Role!) on FIELD_DEFINITION
enum Role { USER SUPPORT ADMIN }

// transform
import { mapSchema, getDirective, MapperKind, defaultFieldResolver } from '@graphql-tools/schema';

export const requiresRoleTransformer = (schema) =>
  mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const d = getDirective(schema, fieldConfig, 'requiresRole')?.[0];
      if (!d) return fieldConfig;
      const resolve = fieldConfig.resolve ?? defaultFieldResolver;
      const required = d.role;
      return {
        ...fieldConfig,
        resolve: (root, args, ctx, info) => {
          const roles = ctx.viewer?.roles ?? [];       // fail closed on missing context
          if (!roles.includes(required)) {
            throw new ForbiddenError('Insufficient role', {
              extensions: { code: 'FORBIDDEN', requiredRole: required },
            });
          }
          return resolve(root, args, ctx, info);
        },
      };
    },
  });

// coverage lint: fail the build, not the request
export function assertDirectiveCoverage(schema) {
  const query = schema.getQueryType();
  for (const [name, field] of Object.entries(query.getFields())) {
    const marks = getDirective(schema, field, 'requiresRole')
      .concat(getDirective(schema, field, 'public'));
    if (marks.length === 0) throw new Error('Query.' + name + ' has no access directive');
  }
}

Edge Cases:

  • Field with no explicit resolver: fall back to defaultFieldResolver so the guard still wraps it
  • Missing ctx.viewer (anonymous): roles default to [] and the check denies
  • Two directives on one field: apply authorization outermost so no other transform runs before the check

Quiz

1. When does a @hasRole directive check execute relative to the field resolver?

Question 1 options

2. A field in Query has no @hasRole and no @public directive. What is its effective access policy?

Question 2 options

3. What does auth metadata in SDL enable for the client, and what does it not do?

Question 3 options

Flashcards

Question

Schema directive for auth

Answer

An SDL directive such as @hasRole(role: ADMIN) on FIELD_DEFINITION that documents and triggers a guard for that field.

Question

Directive transform

Answer

mapSchema + getDirective read SDL metadata and replace the resolver with a wrapper that checks context roles before delegating.

Question

Directive coverage gap

Answer

A field with no directive gets no wrapper — enforce coverage with a schema lint rule plus per-field denial tests.

Question

What directives cannot do

Answer

Check object ownership after fetch, protect service-layer calls that bypass the schema, or secure a client that queries anyway.

Question

defaultFieldResolver fallback

Answer

Wrap it too, or fields without an explicit resolver bypass the guard while still resolving from parent values.

Revision Notes

Key Takeaways

  • 1. Rules in SDL are reviewable, lintable, and diffable — a permission change shows up in the pull request
  • 2. The transform wraps the resolver so the check runs per field execution before any data is read
  • 3. Directives cover field access only: ownership, coverage, and non-schema call paths need their own controls

Interview Tips

  • • Say explicitly where the directive check runs and what it reads from — "it is in the schema" is not an answer to how it enforces
  • • Be ready with the test story: how you prove every sensitive field is covered and actually denies

Cheat Sheet

Authorization Directives Cheat Sheet

SDL

directive @hasRole(role: Role!) on FIELD_DEFINITION

type Query {
  me: User @public
  auditLog: [AuditEntry!]! @hasRole(role: ADMIN)
}

Guard wrapper (graphql-tools)

mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fc) => {
  const d = getDirective(schema, fc, 'hasRole')?.[0];
  if (!d) return fc;                            // no directive = unprotected
  const resolve = fc.resolve ?? defaultFieldResolver;
  return { ...fc, resolve: (r, a, ctx, i) => {
    if (!(ctx.viewer?.roles ?? []).includes(d.role))
      throw new ForbiddenError('...', { extensions: { code: 'FORBIDDEN' } });
    return resolve(r, a, ctx, i);
  }};
}})

Rules: lint for directive coverage · apply auth transform outermost · test denial per sensitive field · object ownership stays in the resolver

Client side: codegen emits typed guards — convenience only, never enforcement

Continue Learning