Skip to content
Ashish.
All posts
Diagram illustrating layered GraphQL authorization with middleware, directives, and resolvers.
6 min readBackendBackend DevelopersFeatured#graphql#authorization#middleware#security#directives#resolvers

GraphQL Authorization Middleware Patterns

Explore GraphQL authorization middleware patterns, field-level directives, and resolver strategies to secure your API effectively.

By Ashish Kumar

Securing a GraphQL API requires a fundamentally different approach than REST. In REST, authorization is often path-based: if a user lacks access to /admin/users, the entire endpoint is blocked. In GraphQL, there is only one endpoint (/graphql). A single query can request sensitive fields (e.g., user.ssn) alongside public fields (e.g., user.name). If you authorize at the HTTP middleware level, you block the entire request, even if the user only wanted the name. If you authorize only at the resolver level, you risk leaking data through introspection or malformed queries before the resolver executes.

The solution is a layered mechanism: coarse-grained checks in middleware and fine-grained checks via directives or resolvers. This post explores how to implement this using middleware patterns, field-level directives, and resolver strategies. By adopting these api security patterns, you ensure that your API remains robust against both broad access violations and targeted data leaks.

The Authorization Problem in GraphQL

GraphQL’s core strength—composable queries—is its security weakness. A client can construct a query that requests a field the user is not authorized to see, alongside fields they are authorized to see.

query {
  currentUser {
    name      # Public
    ssn       # Private: Should fail silently or throw
  }
}

If you rely on traditional REST middleware (like Express.js route guards), you cannot distinguish between name and ssn because both arrive in the same HTTP request to /graphql. Therefore, authorization must happen after the query is parsed but before or during data resolution.

Global Middleware for Coarse-Grained Checks

The first layer of defense is global middleware. This layer should handle authentication (verifying identity) and tenancy (verifying scope), not fine-grained permissions.

In a typical Node.js setup using Apollo Server or GraphQL Yoga, graphql middleware intercepts the request, validates the JWT, and injects the user object into the GraphQL context.

// Middleware: Extract User and Tenant from JWT
const authMiddleware = async (request, response, contextValue, graphqlParams) => {
  const token = request.headers.authorization?.split(' ')[1];
  
  if (!token) {
    throw new Error('Authentication required');
  }
 
  const user = await verifyToken(token); // Mechanism: JWT signature verification
  
  // Inject user into the GraphQL context for downstream use
  return {
    ...contextValue,
    user,
    tenantId: user.tenantId // Critical for multi-tenant isolation
  };
};

Mechanism: This middleware runs before any resolver. It ensures that every subsequent step has access to the authenticated user’s identity. However, it does not know which fields are being requested. For example, it cannot prevent a user from querying Post.secretDraft if they are not an admin, because it doesn’t inspect the query AST (Abstract Syntax Tree).

Field-Level Directives for Fine-Grained Control

To handle field-level authorization, we use GraphQL directives. Directives allow you to attach metadata to schema definitions, which the execution engine can interpret.

Define a custom directive in your schema:

directive @requireRole(role: String!) on FIELD_DEFINITION
 
type User {
  id: ID!
  name: String!
  ssn: String @requireRole(role: "ADMIN")
  posts: [Post!]!
}

How It Works: AST Traversal

When a query is executed, the GraphQL server parses the query into an AST. A directive plugin (often implemented via graphql-shield, envelop, or custom directive resolvers) traverses this AST.

  1. Query Parsing: The server receives the query.
  2. Directive Detection: The plugin identifies fields marked with @requireRole.
  3. Permission Check: For each field, it checks if context.user.roles includes "ADMIN".
  4. Enforcement: If the check fails, the field is either:
    • Blocked: Returns null or throws a ForbiddenError.
    • Filtered: The field is removed from the result set (if using schema filtering).
// Example: Using graphql-shield
const { rule, shield } = require('graphql-shield');
 
const isAdmin = rule()(async (parent, args, ctx) => {
  return ctx.user.isAdmin;
});
 
const permissions = shield({
  Query: {
    currentUser: isAdmin,
  },
});

Trade-off: Directives are declarative and keep authorization logic separate from business logic. However, they can become difficult to maintain as schemas grow. Complex conditions (e.g., "user can edit if they own the post AND the post is not archived") are hard to express in simple directives.

Resolver Layer as a Safety Net

While directives handle static rules, resolvers handle dynamic logic. You should never rely solely on directives for security. Resolvers act as a safety net for complex business rules that cannot be expressed in schema directives. Implementing robust resolver security is essential for handling edge cases that declarative rules miss.

const resolvers = {
  Query: {
    post: async (_, { id }, context) => {
      const post = await db.posts.findById(id);
      
      // Coarse check: Does the user belong to the same tenant?
      if (post.tenantId !== context.tenantId) {
        throw new ForbiddenError('Access denied');
      }
 
      // Fine check: Can the user see the draft status?
      if (post.isDraft && !context.user.isAdmin) {
        // Return a sanitized version or null
        return { ...post, isDraft: false, content: null };
      }
 
      return post;
    },
  },
};

Mechanism: Here, the resolver explicitly checks context.tenantId and context.user.isAdmin. This is imperative code, giving you full control over the error message and the data returned.

Best Practice: Use middleware for tenant isolation (coarse), directives for role-based access control (RBAC) on specific fields, and resolvers for complex ownership or state-based logic.

Synthesis and Trade-offs

PatternScopeBest ForLimitation
MiddlewareRequest-levelAuthentication, Tenant IsolationCannot inspect query fields
DirectivesField-levelRBAC, Static PermissionsHard to express complex logic
ResolversData-levelOwnership, Dynamic RulesVerbose, easy to forget checks
  1. Global Middleware: Validates JWT, extracts userId and tenantId, injects into context.
  2. Directive Plugin: Enforces @requireRole on all fields by default (deny by default).
  3. Resolvers: Handle complex ownership checks (e.g., "can user edit this post?").

This layered approach ensures that even if a directive is missed, the resolver acts as a final gatekeeper. Conversely, if a resolver is bypassed (e.g., via direct DB access), the middleware ensures tenant isolation. By combining these techniques, you establish a comprehensive set of api security patterns that protect your data at every layer.

Conclusion

GraphQL authorization is not a single feature but a pattern of enforcement. By combining middleware for context injection, directives for declarative field-level rules, and resolvers for dynamic logic, you build a defense-in-depth strategy. Start with middleware for authentication, add directives for RBAC, and use resolvers for complex business rules. This ensures your API remains secure without sacrificing the flexibility that makes GraphQL powerful. Ultimately, mastering these graphql middleware strategies and resolver security principles is key to building scalable, secure APIs.

Related posts