GraphQL API Security: Query Complexity en Resolver-Level Protectie

Hoe beveilig je GraphQL APIs tegen resource-intensieve queries, nested attacks en unauthorized data access via query cost analysis en resolver guards.

Jean-Pierre Broeders

Freelance DevOps Engineer

31 maart 20269 min. leestijd
GraphQL API Security: Query Complexity en Resolver-Level Protectie

GraphQL API Security: Query Complexity en Resolver-Level Protectie

REST APIs hebben hun eigen security uitdagingen, maar GraphQL introduceert een hele nieuwe set risico's. De flexibiliteit die GraphQL biedt — clients kunnen exact specificeren welke data ze willen — maakt het ook kwetsbaar voor misbruik.

Een kwaadwillende gebruiker kan queries schrijven die servers platleggen. Denk aan queries met enorme nesting dieptes, of requests die duizenden relaties ophalen in één call. GraphQL heeft geen ingebouwde protectie tegen dit soort aanvallen.

Het Probleem: Query Complexity Attacks

Een typische GraphQL schema heeft relaties tussen types. Bijvoorbeeld:

type User {
  id: ID!
  name: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  author: User!
  comments: [Comment!]!
}

type Comment {
  id: ID!
  text: String!
  author: User!
}

Niets mis met dit schema. Maar wat als een client deze query stuurt?

{
  users {
    posts {
      comments {
        author {
          posts {
            comments {
              author {
                posts {
                  comments {
                    text
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Dit escaleert exponentieel. Elke nesting-laag multipliceert het aantal database queries. Bij 100 users, 50 posts per user, 20 comments per post... Je CPU smelt.

Oplossing 1: Query Depth Limiting

De eerste verdedigingslinie: limiteer hoeveel niveaus deep een query mag gaan.

import { createComplexityLimitRule } from 'graphql-validation-complexity';

const depthLimit = createComplexityLimitRule({
  maxDepth: 5,
  onCost: (cost) => {
    console.log('Query depth:', cost);
  }
});

const server = new ApolloServer({
  schema,
  validationRules: [depthLimit]
});

Simpel maar effectief. Queries dieper dan 5 niveaus worden afgewezen voordat ze überhaupt uitgevoerd worden. Voor de meeste use cases is dit ruim voldoende.

Oplossing 2: Query Cost Analysis

Depth limiting is grof. Sommige queries zijn deep maar goedkoop (een user → profile → settings chain is oké). Andere zijn plat maar duur (alle posts van alle users ophalen).

Query cost analysis geeft elk veld een "kost" en telt het totaal:

import { createComplexityLimitRule } from 'graphql-validation-complexity';

const costLimit = createComplexityLimitRule({
  scalarCost: 1,
  objectCost: 5,
  listFactor: 10,
  maxCost: 5000
});

const server = new ApolloServer({
  schema,
  validationRules: [costLimit],
  plugins: [
    {
      requestDidStart() {
        return {
          validationDidStart(requestContext) {
            console.log('Query complexity:', requestContext.metrics.queryPlanningTime);
          }
        }
      }
    }
  ]
});

Een lijst-veld krijgt hogere kosten. Lists binnen lists escaleren snel. De server weigert queries boven de threshold.

Dit werkt goed, maar vereist fine-tuning. Te streng en je blokkeert legitieme use cases. Te laks en aanvallers komen erdoor.

Oplossing 3: Resolver-Level Authorization

GraphQL resolvers zijn waar de echte data access gebeurt. Hier moet authentication EN authorization plaatsvinden — niet alleen op endpoint-niveau.

Een veelgemaakte fout:

// FOUT: alleen top-level check
app.use('/graphql', authenticate);

const resolvers = {
  Query: {
    users: () => User.findAll(),  // Geen check!
    posts: () => Post.findAll()   // Geen check!
  }
};

Je blokkeert ongeauthenticeerde requests op de endpoint, maar eenmaal binnen kan de user ALLES opvragen. GraphQL introspection laat het hele schema zien.

Beter:

const resolvers = {
  Query: {
    users: async (parent, args, context) => {
      if (!context.user?.isAdmin) {
        throw new ForbiddenError('Admin access required');
      }
      return User.findAll();
    },
    
    me: async (parent, args, context) => {
      if (!context.user) {
        throw new AuthenticationError('Not authenticated');
      }
      return User.findById(context.user.id);
    }
  },
  
  User: {
    email: (user, args, context) => {
      // Email alleen zichtbaar voor de user zelf of admins
      if (context.user?.id === user.id || context.user?.isAdmin) {
        return user.email;
      }
      return null;
    }
  }
};

Elk resolver checkt permissions. Field-level granularity. Geen data lekt buiten de toegestane scope.

Oplossing 4: Rate Limiting op Resolver Niveau

Standaard rate limiting op endpoint-niveau werkt niet goed met GraphQL. Elke request gaat naar /graphql, dus je limit alle queries gelijk — terwijl sommige zwaar zijn en andere licht.

Beter: limit per resolver of per query complexity.

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';

const complexityLimiter = rateLimit({
  store: new RedisStore({ client: redisClient }),
  windowMs: 15 * 60 * 1000, // 15 minuten
  max: (req) => {
    // Dynamische limit gebaseerd op query complexity
    const complexity = req.body.extensions?.complexity || 100;
    return Math.max(1, Math.floor(10000 / complexity));
  },
  keyGenerator: (req) => req.user?.id || req.ip
});

app.use('/graphql', complexityLimiter);

Zware queries tellen zwaarder mee in je rate limit budget. Light queries? Meer toegestaan per tijdseenheid.

Praktische Trade-offs

Geen enkele aanpak is perfect. Hier zijn de afwegingen:

MethodeVoordelenNadelen
Depth LimitingSimpel te implementeren, lage overheadBlokkeert valide deep queries
Cost AnalysisNauwkeuriger resource schattingComplexe configuratie, moeilijk te tunen
Resolver AuthGranulaire toegangscontroleVeel boilerplate code
Dynamic Rate LimitingEerlijke resource verdelingRequires state (Redis), complexer

Voor de meeste productie setups: combineer depth limiting (hard max) met cost analysis (soft max) en field-level authorization. Rate limiting is nice-to-have maar niet essentieel als je complexity goed onder controle hebt.

Monitoring en Alerting

Security zonder visibility is blindvliegen. Log query complexity metrics:

const server = new ApolloServer({
  schema,
  plugins: [
    {
      requestDidStart() {
        return {
          executionDidStart() {
            return {
              willResolveField({ info }) {
                const start = Date.now();
                return () => {
                  const elapsed = Date.now() - start;
                  if (elapsed > 1000) {
                    console.warn(`Slow resolver: ${info.parentType}.${info.fieldName} (${elapsed}ms)`);
                  }
                };
              }
            };
          }
        };
      }
    }
  ]
});

Track welke resolvers traag zijn. Alert bij abnormale query patterns. Identificeer bottlenecks voordat ze problemen veroorzaken.

Conclusie

GraphQL biedt krachtige flexibiliteit, maar dat komt met verantwoordelijkheid. Depth limits voorkomen de meest voor de hand liggende attacks. Cost analysis geeft fijnmazige controle. Resolver-level authorization zorgt dat data niet lekt. Rate limiting houdt misbruik in toom.

De meeste beveiligingsproblemen ontstaan niet door gebrekkige tools, maar door gebrek aan bewustzijn. GraphQL is veilig als je begrijpt waar de risico's zitten — en proactief beschermt tegen resource exhaustion en unauthorized access.

Implementeer deze lagen defensief, monitor actief, en pas aan op basis van echte usage patterns. Dat schaalt beter dan achteraf firefighten.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op