GraphQL-Auth: Authentifizierung und Autorisierung im Context
Warum Auth in GraphQL nicht ins Schema, sondern in den context gehört – und wie man Identität und Berechtigungen sauber trennt, statt beides zu verwechseln.
Wenn ich in einer GraphQL-Schulung die Frage stelle "Wo baut ihr eigentlich eure Auth ein?", kommt fast immer dieselbe Antwort: "Wir prüfen das im Resolver." Gut. Nächste Frage: "Und was genau prüft ihr da – ob jemand eingeloggt ist, oder ob er das Feld sehen darf?" Dann wird es still. Genau an dieser Stelle steckt der Kern der ganzen Sache, und ich glaube, die meisten Probleme mit GraphQL-Auth entstehen, weil zwei völlig verschiedene Dinge in einen Topf geworfen werden.
Deshalb fange ich in Schulungen immer mit einer Trennung an, die banal klingt, aber viel klärt.
Zwei Fragen, nicht eine
Authentifizierung beantwortet die Frage: Wer bist du? Aus einem Token wird eine Identität – ein user. Autorisierung beantwortet eine ganz andere Frage: Was darfst du? Ob dieser konkrete Nutzer dieses konkrete Feld oder diese konkrete Mutation aufrufen darf.
Das sind unterschiedliche Verantwortlichkeiten, und sie haben sogar unterschiedliche Fehlerbilder. Wer kein oder ein ungültiges Token schickt, bekommt einen 401 – nicht authentifiziert. Wer ein gültiges Token hat, aber die falsche Rolle, bekommt einen 403 – authentifiziert, aber nicht berechtigt. Teilnehmer verwechseln das gern, und dann landet am Ende beides als HTTP 500 beim Client, weil ein Fehler nicht abgefangen wurde. Das ist kein kosmetisches Problem – ein Client kann auf 401 und 403 sinnvoll reagieren (Login-Flow vs. "kein Zugriff"), auf 500 nicht.
Wenn diese Trennung sitzt, kommt die eigentliche Architekturfrage: Wo gehört die Identität hin, und wie kommt sie zu jedem Resolver, der sie braucht?
Warum der context der richtige Ort ist
In GraphQL wird pro Request ein contextValue gebaut – ein Objekt, das an jeden Resolver desselben Requests durchgereicht wird. Das ist kein Zufallsdetail, das ist der ideale Aufhängepunkt für Auth.
Der Grund ist die Lebensdauer. Der context wird einmal pro Request neu erzeugt und lebt genau so lange wie dieser Request. Kein State leakt zwischen zwei Requests, kein Cleanup ist nötig, und ich habe garantiert für jede Anfrage eine frisch ermittelte Identität. Genau dort gehört der user hin.
In Apollo Server 4 – dem aktuellen Major – ist das eine async-Funktion, die man dem Server-Start mitgibt. Wichtig: In Apollo Server 4 wandert die context-Funktion aus dem new ApolloServer({...})-Konstruktor heraus zu startStandaloneServer beziehungsweise expressMiddleware. Wer noch ältere Beispiele aus AS2/AS3 im Kopf hat, wo context direkt im Konstruktor stand: konzeptionell identisch, nur die Verdrahtung ist neu.
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
const raw = req.headers.authorization || '';
const token = raw.replace('Bearer ', '');
const user = await getUserFromToken(token);
return { user };
},
});
Zwei Details, an denen in Schulungen regelmäßig jemand hängen bleibt. Erstens: req.headers.authorization ist in Express immer klein geschrieben – die HTTP-Header-Keys landen dort lowercase, egal wie der Client sie sendet. Zweitens: Ein leerer Header darf niemals als "eingeloggt" durchgehen. Der || ''-Fallback liefert einen leeren String, getUserFromToken('') gibt dann sauber null zurück – und null heißt eben "unbekannt", nicht "Admin".
Der Ablauf sieht damit für jeden Request gleich aus:
flowchart LR C[Client] -- "Authorization:<br/>Bearer token" --> S[GraphQL-Server] S --> CTX["context()<br/>Token zu user"] CTX --> R[Resolver] R --> BL[Business-Logik<br/>prueft user + Rolle] BL -- erlaubt --> DATA[Daten] BL -- verweigert --> ERR["GraphQLError<br/>403 FORBIDDEN"]
Man sieht hier gut: Die Authentifizierung passiert genau einmal, ganz vorn in context(). Ab da trägt der Request eine Identität mit sich, und alles Weitere ist Autorisierung.
Was in den context gehört – und was nicht
Weil der context so verlockend praktisch ist, wird er gern zur Rumpelkammer. Deshalb gebe ich in Schulungen eine grobe Richtschnur mit, was dort hingehört:
- die Identität –
useroderviewer, aus dem Token ermittelt - die Berechtigungen dieses Nutzers –
rolesoderscopes - request-gebundene Infrastruktur – DataLoader-Instanzen, damit Batching und Deduplizierung pro Request funktionieren
- Zugänge zur Datenschicht –
db-Handle oder Repositories - request-spezifische Werte wie Locale oder ein durchzureichendes Auth-Token für nachgelagerte Services
Und ebenso klar, was nicht hineingehört:
- globaler State, der über Requests hinweg lebt
- Cross-Request-Caches (ein DataLoader ist genau deshalb pro Request, nicht global)
- Secrets im Klartext, die dort nichts verloren haben
Die Faustregel dahinter: Alles, was für genau diesen einen Request gilt und danach weg darf, ist im context gut aufgehoben. Alles Langlebige gehört woanders hin. Wer DataLoader-Instanzen pro Request in den context legt, kennt das Muster übrigens schon aus dem Umfeld von effizientem Datenzugriff – ich habe das im Zusammenhang mit Listen und Cursorn in GraphQL-Paginierung ausführlicher beschrieben.
Autorisierung im Resolver
Jetzt ist der user im context, und jeder Resolver kann ihn lesen – als drittes Argument, (parent, args, contextValue, info). Das ist die einfachste, direkteste Form der Autorisierung.
import { GraphQLError } from 'graphql';
const resolvers = {
Query: {
me: (parent, args, { user }) => {
if (!user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } },
});
}
return user;
},
adminReport: (parent, args, { user }) => {
if (!user?.roles.includes('ADMIN')) {
throw new GraphQLError('Forbidden', {
extensions: { code: 'FORBIDDEN', http: { status: 403 } },
});
}
return buildAdminReport();
},
},
};
Wichtig für alle, die noch mit älteren Apollo-Beispielen arbeiten: Die früheren Klassen AuthenticationError und ForbiddenError aus dem alten apollo-server-Paket gibt es in Apollo Server 4 nicht mehr. Der Weg heute ist GraphQLError aus graphql mit einem extensions.code – UNAUTHENTICATED für den fehlenden Login, FORBIDDEN für die fehlende Berechtigung – und optional dem passenden HTTP-Status.
Man erkennt an den beiden Resolvern schon die Trennung: me prüft nur, ob überhaupt jemand da ist (Authentifizierung durchgesetzt), adminReport prüft was dieser Jemand darf (Autorisierung). Zwei Fragen, zwei Fehlercodes.
Feld-Ebene versus Query-Ebene
Autorisierung ist nicht nur eine Frage von "ganze Query erlaubt oder nicht". GraphQL erlaubt Prüfungen auf Feld-Ebene, und das ist eine seiner stärkeren Eigenschaften.
Weil jedes Feld seinen eigenen Resolver hat und die Prüfung während der Ausführung des Requests greift, sind Partial Responses möglich: Der Client fragt zehn Felder ab, sieben liefert der Server, für drei bekommt er einen Fehler und null – in einer einzigen Antwort. Das ist genau das Verhalten, das man will. Ein Nutzer soll seinen eigenen Namen sehen, aber nicht das interne Bonitäts-Feld, das nur Admins zusteht.
Im Grounding-Repo taucht dafür ein deklarativer Ansatz auf: eine @auth-Directive mit einem Rollen-Enum.
enum Role {
ADMIN
OWNER
USER
}
directive @auth(requires: Role!) on FIELD_DEFINITION
type User {
id: ID!
name: String!
role: String @auth(requires: ADMIN)
}
type Query {
me: User @auth(requires: USER)
}
Hier sieht man beide Ebenen: me @auth(requires: USER) schützt ein ganzes Query-Feld, role @auth(requires: ADMIN) schützt ein einzelnes Feld innerhalb des Typs. Die Directive selbst ist nur eine Deklaration – irgendjemand muss sie zur Laufzeit auch durchsetzen. In der aktuellen graphql-tools-Generation macht man das mit einem Schema-Transformer.
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLError } from 'graphql';
function authDirectiveTransformer(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const directive = getDirective(schema, fieldConfig, 'auth')?.[0];
if (!directive) return fieldConfig;
const { requires } = directive;
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = (source, args, context, info) => {
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } },
});
}
if (!context.user.roles.includes(requires)) {
throw new GraphQLError('Forbidden', {
extensions: { code: 'FORBIDDEN', http: { status: 403 } },
});
}
return resolve(source, args, context, info);
};
return fieldConfig;
},
});
}
Ein Hinweis für alle, die ältere Tutorials finden: Der frühere SchemaDirectiveVisitor ist längst raus – seit graphql-tools v6 gibt es ihn nicht mehr. Der Weg über mapSchema und getDirective ist der aktuelle. Wer nach SchemaDirectiveVisitor googelt und es kopiert, wundert sich dann, warum nichts importierbar ist.
So elegant die Directive aussieht: Sie ist nur die halbe Wahrheit. Und genau hier fangen in Schulungen die interessanten Diskussionen an.
Warum Auth nicht ins Schema gehört
Die @auth-Directive fühlt sich zunächst wie die perfekte Lösung an – Berechtigungen stehen direkt am Feld, deklarativ, lesbar. Für Prototypen und zum Lernen ist das auch völlig in Ordnung. Aber als alleinige Strategie führt sie in eine Sackgasse, und die offizielle GraphQL-Doku ist da erstaunlich deutlich: Autorisierung gehört in die Business-Logik-Schicht, nicht ins Schema.
Der Grund ist Duplikation. Nehmen wir eine typische Regel: OWNER – ein Nutzer darf nur seine eigenen Posts bearbeiten. Diese Regel lässt sich rein deklarativ kaum abbilden, denn sie hängt von den konkreten Daten ab: Gehört dieser Post diesem Nutzer? Eine Directive kennt die Rolle, aber nicht die Beziehung zwischen Nutzer und Ressource.
Und selbst simple Rollenchecks laufen auseinander, sobald dieselbe Logik an mehreren Stellen gebraucht wird. Wenn dieselbe "darf-Post-lesen"-Entscheidung an drei Feldern hängt und irgendwann auch noch von einem REST-Endpunkt oder einem Background-Job gebraucht wird, dann hat man die Regel dreimal, viermal – und sie driftet auseinander. Das ist der Kern des Arguments: eine einzige Quelle der Wahrheit statt verstreuter Kopien.
Die saubere Variante ist deshalb: Der Resolver entscheidet nicht selbst, er delegiert an eine Business-Logik-Funktion, die den context bekommt und die Entscheidung trifft.
const resolvers = {
Query: {
posts: (parent, args, context) => postRepository.getPosts(context),
},
Mutation: {
updatePost: (parent, { id, input }, context) =>
postRepository.updatePost(id, input, context),
},
};
// Business logic layer – authorization lives here
const postRepository = {
async getPosts(context) {
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } },
});
}
// Owner rule: only the user's own posts
return db.posts.findMany({ where: { authorId: context.user.id } });
},
};
Der Resolver bleibt dünn und dumm – er nimmt args und context und reicht sie weiter. Die eigentliche Entscheidung, inklusive der datenabhängigen OWNER-Regel, liegt an einer einzigen Stelle. Wenn morgen ein anderer Einstiegspunkt dazukommt, ruft der dieselbe Funktion auf. Keine Duplikation, kein Drift.
Ich stelle mir das gern als drei Schichten vor, mit dem Schema bewusst in der Mitte und ohne Entscheidungsgewalt:
flowchart TB
subgraph Transport["Transport-Schicht"]
A["Authentifizierung<br/>Token zu user im context"]
end
subgraph Schema["Schema-Schicht"]
B["nur deklarativ<br/>optional @auth als Hinweis"]
end
subgraph Domain["Business-Logik-Schicht"]
D["Autorisierung<br/>darf user diese Ressource?"]
end
Transport --> Schema --> Domain
Die Botschaft dieses Bildes: Identität wird oben ermittelt, die Berechtigungsentscheidung fällt unten. Das Schema in der Mitte beschreibt nur, es entscheidet nicht.
Der Fallstrick, der am teuersten wird
Es gibt einen Fehler, den ich immer wieder sehe und der richtig weh tut: Autorisierung im Client durchsetzen. Die UI blendet den "Löschen"-Button aus, wenn der Nutzer kein Admin ist – und alle sind zufrieden. Nur prüft der Resolver dahinter gar nichts.
Das ist keine Sicherheit, das ist Kosmetik. Jeder, der die Developer-Tools öffnet oder direkt eine GraphQL-Mutation abschickt, umgeht diese "Prüfung" in Sekunden. Ausgeblendete Buttons sind eine Frage der Nutzerführung, nicht der Sicherheit. Die Durchsetzung muss serverseitig passieren – im Resolver beziehungsweise in der Business-Logik-Schicht dahinter. Der Client darf gern zusätzlich Buttons ausblenden, damit die Oberfläche aufgeräumt ist. Aber verlassen darf man sich nur auf den Server.
Damit schließt sich der Kreis zur Anfangsfrage aus der Schulung. "Wir prüfen das im Resolver" ist die richtige Antwort – aber nur, wenn klar ist, dass dort zwei verschiedene Prüfungen stattfinden: erst wer, dann was. Die Identität kommt einmal pro Request in den context. Die Berechtigung entscheidet der Server, nah an den Daten, an einer einzigen Stelle. Alles andere – Directives, Client-Checks, ausgeblendete Buttons – ist Komfort obendrauf, niemals das Fundament.
Wenn ich am Ende einer Schulung eine Sache mitgeben darf, dann diese: Auth ist keine Schema-Frage, sondern eine Frage der Lebenszyklen und der Verantwortlichkeiten. Der context weiß, wer du bist. Die Business-Logik weiß, was du darfst. Halte die beiden getrennt, und der Rest wird einfach.
Weiterführende Quellen
- Grounding-Repository MikeBild/introduction-graphql –
introduction-graphql/auth.mdundrecipes/auth/ - graphql.org – Authorization – warum Autorisierung in die Business-Logik-Schicht gehört
- Apollo Server – Authentication and authorization – GraphQLError-Codes,
UNAUTHENTICATEDvs.FORBIDDEN - Apollo Server – The context object – context und contextValue, einmal pro Request
Kommentare
Kommentar schreiben