Authentication and field-level authorization

Authenticating in context, guarding operations, field-level rules, schema directives for policy, and depth and complexity limits.

Authentication in context

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: async ({ req }) => {
    const token = req.headers.authorization?.replace("Bearer ", "");
    let user = null;
    if (token) {
      try {
        user = await verifyJwt(token);
      } catch {
        throw new GraphQLError("Invalid or expired token", {
          extensions: { code: "UNAUTHENTICATED" },
        });
      }
    }
    return { user, db, loaders: createLoaders() };
  },
});

const requireUser = (ctx) => {
  if (!ctx.user) {
    throw new GraphQLError("You must be signed in", { extensions: { code: "UNAUTHENTICATED" } });
  }
  return ctx.user;
};

const requireAdmin = (ctx) => {
  const user = requireUser(ctx);
  if (user.role !== "admin") {
    throw new GraphQLError("Not permitted", { extensions: { code: "FORBIDDEN" } });
  }
  return user;
};
⚠️
An operation-level check is not enough. query { book { author { email } } } reaches many resolvers from one authorised root field. Every resolver that reads protected data must check its own permission.

Field-level rules

const resolvers = {
  Query: {
    me: (_p, _a, ctx) => requireUser(ctx),
  },
  Book: {
    // only the owner or a moderator sees the draft body
    draftBody: (book, _a, ctx) => {
      const user = requireUser(ctx);
      if (book.authorId !== user.id && user.role !== "moderator") {
        throw new GraphQLError("Not permitted", { extensions: { code: "FORBIDDEN" } });
      }
      return book.draftBody;
    },
  },
  Author: {
    email: (author, _a, ctx) => {
      requireAdmin(ctx);
      return author.email;
    },
  },
};

// a reusable field wrapper
const authorize = (check) => (resolver) => (parent, args, ctx, info) => {
  check(ctx, parent, args);
  return resolver(parent, args, ctx, info);
};
PatternWhereTrade-off
Check in the resolverEvery protected fieldExplicit and greppable, some repetition
Higher-order resolver wrapperAround the resolverConcise, one indirection
Schema directiveIn the SDLVisible in the schema, needs a schema transformer
Data layer scopingRepository or query builderStrongest - cannot be forgotten, but it is coarse

The most robust layer is the data query itself: scope by the user id in the repository so an unprotected resolver returns nothing rather than someone else's data. Treat resolver checks as the second line of defence.

Depth, complexity and rate limits

import depthLimit from "graphql-depth-limit";
import { createComplexityLimitRule } from "graphql-validation-complexity";

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(8),
    createComplexityLimitRule(1000, { onCost: (cost) => log.debug({ cost }, "query cost") }),
  ],
});

// alias-based abuse: one query asking for the same expensive field 500 times
// { a: expensiveQuery b: expensiveQuery c: expensiveQuery ... }
  • Depth limits stop recursive nesting; complexity limits stop wide queries with many aliases. You usually need both.
  • A single deep query can be more expensive than a thousand shallow ones - rate limiting by request count alone is not enough.
  • For expensive public operations, require a persisted query or a signed query hash so an attacker cannot invent arbitrary documents.
  • Log the operation name with the cost so you can see which client is responsible.

FAQ

Should authentication failures return 200 with an error?
For a request with no valid credentials at all, returning 401 at the transport level is clearer for proxies and monitoring. Once the caller is identified, per-field authorization failures are ordinary GraphQL errors with a code.
How do I hide a field that a user cannot see?
Choose deliberately: null with an error tells the client the field exists; throwing FORBIDDEN is the most common choice. Removing the field from the schema per user is possible but breaks client-side caching and code generation.

Building a GraphQL server DataLoader and solving the N+1 problem

Last refreshed 2026-09-18.