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);
};| Pattern | Where | Trade-off |
|---|---|---|
| Check in the resolver | Every protected field | Explicit and greppable, some repetition |
| Higher-order resolver wrapper | Around the resolver | Concise, one indirection |
| Schema directive | In the SDL | Visible in the schema, needs a schema transformer |
| Data layer scoping | Repository or query builder | Strongest - 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.
Related
Building a GraphQL server DataLoader and solving the N+1 problem
Last refreshed 2026-09-18.