GraphQL absichern: Query-Tiefe, Komplexität und Rate-Limiting
Ein einziger GraphQL-Endpunkt nimmt beliebig verschachtelte Queries an – das ist bequem und zugleich ein Missbrauchsrisiko. Ich zeige, wie ich mit Depth-Limit, Complexity-Budget, Pagination-Caps und Rate-Limiting eine mehrschichtige Verteidigung gegen teure Queries baue.
Der große Charme von GraphQL ist zugleich seine größte Angriffsfläche: Ein einziger Endpunkt akzeptiert per Design beliebig verschachtelte Queries. Der Client entscheidet, was er will, und der Server liefert. Für die Developer Experience ist das ein Gewinn. Für den Betrieb heißt es: Ich habe eine Schnittstelle, deren Kostenprofil ich nicht kenne, bis die Query hereinkommt.
In fast jedem GraphQL-Review, das ich mache, stelle ich dieselbe Frage: „Was passiert, wenn jemand hier eine zwanzigfach verschachtelte Query mit first: 1000 auf jeder Ebene schickt?" Die Antwort ist erschreckend oft ein Schulterzucken. Dabei ist genau das der Unterschied zwischen einer API, die man ins Internet stellen kann, und einer, die beim ersten unfreundlichen Client umfällt.
In diesem Beitrag geht es nicht um Authentifizierung oder Autorisierung – das habe ich in GraphQL-Auth im Context behandelt. Hier geht es um etwas Orthogonales: den Schutz vor teuren und böswilligen Queries. Ein sauber authentifizierter, autorisierter Nutzer kann Ihren Server trotzdem in die Knie zwingen. Wer nur Auth hat, ist gegen diese Klasse von Problemen ungeschützt.
Warum ein Endpunkt gefährlich wird
Das Kernproblem sind zyklische Beziehungen im Schema. Sobald ein User seine posts hat und ein Post seinen author – der wiederum seine posts hat – ist die Tiefe formal unbegrenzt:
query MaliciousDepth {
user(id: "1") {
posts {
author {
posts {
author {
posts {
author {
# ... and so on, as deep as the client wants
name
}
}
}
}
}
}
}
}
Jede zusätzliche Ebene multipliziert die Zahl der Resolver-Aufrufe. Kombiniert mit unbegrenzten Listen wird daraus schnell eine Query, die Millionen von Datenbankzugriffen auslöst – ein klassischer Denial-of-Service, ganz ohne Botnet, mit einem einzigen HTTP-Request. Das ist kein hypothetisches Lehrbuch-Szenario. Ich habe produktive APIs gesehen, die genau so kippten, weil ein Frontend-Bug versehentlich eine rekursive Query erzeugte.
Die gute Nachricht: Verteidigung funktioniert hier gut in Schichten. Keine einzelne Maßnahme genügt, aber die Kombination macht die Angriffsfläche beherrschbar. Der Ablauf, den ich anstrebe, sieht so aus:
flowchart TD
A[Client-Request] --> B{Rate-Limit<br/>am Edge}
B -->|Budget überschritten| R1[429 Too Many Requests]
B -->|ok| C{Depth Gate<br/>graphql-depth-limit}
C -->|zu tief| R2[Validation Error]
C -->|ok| D{Complexity Gate<br/>Cost-Budget}
D -->|über Budget| R3[Validation Error]
D -->|ok| E[Resolver-Ausführung<br/>mit Pagination-Caps]
E --> F[Response]
Die entscheidende Eigenschaft: Depth- und Complexity-Prüfung greifen in der Validierungsphase, also bevor ein einziger Resolver läuft. Eine abgelehnte Query kostet mich fast nichts – genau das will ich.
Schicht 1: Query-Tiefe begrenzen
Der einfachste und wirkungsvollste erste Schritt ist ein Limit auf die Schachtelungstiefe. In Apollo Server 4 hängt man dafür eine Validation Rule in die validationRules des Konstruktors. Die verbreitete Bibliothek dafür ist graphql-depth-limit:
import { ApolloServer } from "@apollo/server";
import depthLimit from "graphql-depth-limit";
import { typeDefs } from "./schema";
import { resolvers } from "./resolvers";
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
// Reject any operation nested deeper than 7 levels.
depthLimit(7),
],
});
depthLimit(maxDepth) liefert eine graphql-js Validation Rule, die während der Validierung die tiefste Verschachtelung zählt und Operationen über dem Limit ablehnt. Introspektionsfelder wie __schema und __type werden per Default ignoriert, damit Tools weiter funktionieren.
Ein Wort der Warnung, das 2024 dazugehört: graphql-depth-limit (v1.1.0) wird seit 2018 nicht mehr gepflegt und zählt ausschließlich die Selektionstiefe – nicht die Tiefe von Listen. Für viele Projekte reicht das als erste Schicht, aber ich weise im Review immer darauf hin. Wer strenger sein will, kann sich @graphile/depth-limit ansehen, das auch List-Tiefe berücksichtigt.
Wichtig ist die konzeptionelle Grenze: Depth-Limiting kennt keine Feld-Kosten. Eine flache Query, die dafür tausend teure Felder auf einer Ebene selektiert, läuft anstandslos durch. Tiefe ist eben nur eine Dimension des Missbrauchs.
Schicht 2: Complexity- und Cost-Analyse
Damit sind wir bei der zweiten Schicht, die die Schwäche der ersten ausgleicht. Statt nur Tiefe zu zählen, vergebe ich Kosten pro Feld und lehne jede Query ab, die ein Gesamtbudget überschreitet. Das etablierte Werkzeug dafür ist graphql-query-complexity von slicknode.
Der Kern ist createComplexityRule, das ich mit einem maximumComplexity-Budget und einer Liste von Estimators konfiguriere. Estimators sind die Regeln, nach denen Kosten berechnet werden – sie werden der Reihe nach befragt, bis einer einen Wert liefert:
import { ApolloServer } from "@apollo/server";
import depthLimit from "graphql-depth-limit";
import {
createComplexityRule,
simpleEstimator,
fieldExtensionsEstimator,
} from "graphql-query-complexity";
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(7),
createComplexityRule({
maximumComplexity: 1000,
estimators: [
// Prefer explicit costs declared on the schema fields...
fieldExtensionsEstimator(),
// ...and fall back to a flat cost of 1 per field.
simpleEstimator({ defaultComplexity: 1 }),
],
onComplete: (complexity) => {
console.log(`Query complexity: ${complexity}`);
},
}),
],
});
Die eigentliche Modellierung passiert an den Feldern selbst. Über fieldExtensionsEstimator lese ich Kosten aus den Schema-Extensions – und genau hier bilde ich Listen realistisch ab. Ein Feld, das eine Liste zurückgibt, kostet nicht konstant, sondern proportional zum angeforderten first-Wert:
import { GraphQLObjectType, GraphQLList } from "graphql";
import type { ComplexityEstimatorArgs } from "graphql-query-complexity";
const UserType = new GraphQLObjectType({
name: "User",
fields: {
posts: {
type: new GraphQLList(PostType),
args: { first: { type: GraphQLInt } },
extensions: {
// Cost scales with the number of requested items.
complexity: ({ args, childComplexity }: ComplexityEstimatorArgs) => {
const requested = args.first ?? 20;
return childComplexity * requested;
},
},
},
},
});
Das ist der Punkt, an dem die Cost-Analyse ihre Stärke ausspielt. Eine tiefe Query mit kleinen Listen kann günstiger sein als eine flache mit riesigen. Das Budget von 1000 ist dabei kein magischer Wert – ich leite ihn aus realen Query-Mustern ab, indem ich onComplete erst einmal nur loggen lasse und beobachte, wo legitime Clients landen, bevor ich den Riegel scharf schalte.
Ein Detail zur Apollo-Server-4-Integration, das gern Zeit kostet: Für die reine Ablehnung reicht die Validation Rule oben. Wer die Komplexität aber im Request-Lifecycle weiterverarbeiten will – etwa fürs Kosten-Accounting pro API-Key – nutzt ein eigenes Plugin und berechnet die Komplexität im didResolveOperation-Hook mit getComplexity(). Der fertige AS3-Plugin-Wrapper createComplexityPlugin passt nicht mehr direkt in AS4, deshalb ist der Plugin-Weg von Hand kurz erwähnenswert:
import type { ApolloServerPlugin } from "@apollo/server";
import { getComplexity, simpleEstimator } from "graphql-query-complexity";
import { separateOperations } from "graphql";
const complexityPlugin: ApolloServerPlugin = {
async requestDidStart() {
return {
async didResolveOperation({ request, document, schema }) {
const complexity = getComplexity({
schema,
operationName: request.operationName,
query: request.operationName
? separateOperations(document)[request.operationName]
: document,
variables: request.variables,
estimators: [simpleEstimator({ defaultComplexity: 1 })],
});
if (complexity > 1000) {
throw new Error(
`Query is too expensive: ${complexity}. Maximum allowed: 1000.`,
);
}
},
};
},
};
Schicht 3: Pagination-Obergrenzen
Die Cost-Regel kann Listen nur dann korrekt bewerten, wenn ich Listen überhaupt begrenze. Ein first-Argument ohne serverseitiges Maximum ist eine offene Flanke: Ein einziger Aufruf mit first: 100000 löst zehntausende Resolver aus. Deshalb klemme ich jeden Pagination-Parameter serverseitig ab, unabhängig davon, was der Client wünscht:
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;
const resolvers = {
User: {
posts: (parent, args, context) => {
// Never trust the client-supplied page size.
const first = Math.min(args.first ?? DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE);
return context.dataSources.posts.byAuthor(parent.id, { first });
},
},
};
Wer sein Schema sauber nach dem Connections-Muster baut, hat hier ohnehin einen natürlichen Ort für diese Grenze – wie das strukturell aussieht, habe ich in GraphQL-Paginierung und Connections beschrieben. Die Cap gehört in den Resolver oder eine gemeinsame Helper-Funktion, nicht in die Dokumentation als Bitte an den Client.
Schicht 4: Rate-Limiting
Depth und Complexity begrenzen die Kosten einer einzelnen Query. Sie sagen nichts darüber, wie viele Queries pro Sekunde jemand feuert. Rate-Limiting ist kein Apollo-Kern-Feature, und meiner Erfahrung nach ist die zuverlässigste Stelle dafür die Transport-Ebene – ein Reverse-Proxy oder eine Middleware vor dem GraphQL-Handler:
import express from "express";
import rateLimit from "express-rate-limit";
const app = express();
const limiter = rateLimit({
windowMs: 60_000, // one minute
max: 120, // limit each IP to 120 requests per window
standardHeaders: true,
legacyHeaders: false,
});
app.use("/graphql", limiter);
Für feingranulare Limits pro Feld gibt es Directive-basierte Ansätze wie graphql-rate-limit mit einer @rateLimit-Directive im Schema. Das ist mächtig, aber auch mehr Betriebskomplexität. Meine Faustregel: ein grober Rate-Limit am Edge zuerst, feingranulare Directives nur dort, wo ein konkretes Feld ein nachgewiesenes Problem ist.
Persisted Queries und Allowlisting – mit Vorsicht
An dieser Stelle kommt regelmäßig der Vorschlag: „Lass uns Automatic Persisted Queries (APQ) einschalten, dann ist das Problem gelöst." Das ist ein verbreitetes Missverständnis, das ich klarstellen möchte.
APQ ist ein Performance-Feature. Der Client sendet statt der vollen Query nur einen SHA-256-Hash; kennt der Server den Hash nicht, antwortet er mit PERSISTED_QUERY_NOT_FOUND, der Client schickt die Query einmalig nach, und der Server cached sie. Das spart Bandbreite. Es ist keine Allowlist – APQ registriert jede beliebige eingehende Operation automatisch, auch die böswillige.
const server = new ApolloServer({
typeDefs,
resolvers,
// Performance optimization, NOT a security control.
persistedQueries: { ttl: 900 },
});
Echtes Safelisting ist etwas anderes: eine vorab registrierte Liste erlaubter Operationen, gegen die der Server prüft. Alles, was nicht auf der Liste steht, wird abgelehnt – egal wie tief oder teuer. In der Apollo-Welt läuft das über eine Persisted Query List in GraphOS zusammen mit dem Apollo Router, ein Enterprise-Feature, bei dem APQ dann bewusst deaktiviert ist. Für interne APIs mit einem bekannten Satz von Client-Operationen ist das die stärkste Verteidigung überhaupt, weil die Menge möglicher Queries endlich und geprüft wird. Für eine öffentliche API mit beliebigen Clients ist es dagegen keine Option – dort tragen Depth und Complexity die Last.
Introspection in Produktion
Der letzte Baustein wird oft überschätzt. Das Abschalten der Introspection in Produktion (introspection: false) verhindert, dass ein Angreifer das Schema bequem abfragt. Das ist ein sinnvoller Default für nicht-öffentliche APIs – aber ich nenne es bewusst nicht Sicherheit, sondern Bequemlichkeitsentzug für Angreifer. Es ist Security by Obscurity: Wer das Schema kennt oder rät, kommt an jeder deaktivierten Introspection vorbei. Introspection abzuschalten ersetzt kein einziges der echten Limits oben. Es ist ein netter Zusatz, kein Fundament.
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== "production",
});
Dazu gehört auch ein serverseitiger Request-Timeout, damit eine Query, die trotz aller Gates lange läuft, nicht unbegrenzt Ressourcen bindet. Das ist die Notbremse hinter allen anderen Schichten.
Die Schichten im Überblick
Zusammengefasst ist das die Verteidigung, die ich für einen produktiven GraphQL-Endpunkt einziehe – von außen nach innen:
- Rate-Limiting am Edge begrenzt, wie viele Requests pro Zeitfenster überhaupt durchkommen.
- Persisted Queries oder eine Allowlist lassen bei geschlossenem Client-Kreis nur bekannte Operationen zu.
- Ein Depth-Limit kappt die Schachtelungstiefe schon vor der Ausführung.
- Das Complexity-Budget vergibt Kosten pro Feld und lehnt zu teure Queries ab.
- Pagination-Caps verhindern unbegrenzte Listen und machen die Cost-Analyse überhaupt erst korrekt.
- Ein Resolver-Timeout ist die Notbremse für alles, was trotzdem durchrutscht.
Keine dieser Schichten ist für sich vollständig. Depth ohne Complexity übersieht breite Queries. Complexity ohne Pagination-Caps rechnet mit falschen Multiplikatoren. Rate-Limiting ohne Cost-Analyse lässt einzelne teure Queries durch. Erst im Zusammenspiel entsteht ein Endpunkt, dessen schlimmster Fall ich kenne und beherrsche.
Fazit
Die Flexibilität von GraphQL verlagert Kontrolle vom Server zum Client – und damit auch die Möglichkeit, den Server zu überlasten. Der häufigste Fehler in der Praxis ist die Annahme, Authentifizierung reiche als Schutz. Sie tut es nicht: Der Angriff über teure Queries ist orthogonal zur Frage, wer der Nutzer ist. Ein legitimer, angemeldeter Client kann durch einen simplen Bug dieselbe DoS-Query erzeugen wie ein Angreifer mit Absicht.
Mein Vorgehen im Review ist deshalb immer dasselbe: Erst das Kostenprofil sichtbar machen (Complexity loggen), dann die Gates von grob nach fein einziehen – Depth-Limit und Pagination-Caps als schnelle Gewinne, Complexity-Budget als eigentliches Rückgrat, Rate-Limiting als Rahmen, Allowlist wo der Client-Kreis es zulässt. Introspection und Timeouts runden ab, tragen aber nicht. Wer diese Schichten hat, kann seinen einen Endpunkt beruhigt ins Internet stellen – denn der schlimmste anzunehmende Request ist dann keine offene Frage mehr, sondern eine bekannte Größe.
Kommentare