# GraphQL-Auth: Identität im Context, Autorisierung in der Fachlogik

URL: https://www.mikebild.dev/de/blog/graphql-auth-im-context/

Ein Resolver, der nur prüft, ob ein Token da ist, gibt jedem eingeloggten Nutzer alles frei, was das Schema anbietet. Das ist kein Detailfehler in der Auth, das ist ein offenes Backend mit einem Login-Formular davor.

Die Ursache steckt fast immer an derselben Stelle: In dieser einen Prüfung stecken zwei Fragen, die mit ganz verschiedenen Dingen zu tun haben – und im Code sind sie zu einer verschmolzen. "Wir prüfen das im Resolver" ist deshalb eine Antwort, die alles offen lässt: Geprüft wird, ob jemand eingeloggt ist, oder ob er dieses Feld sehen darf? Solange das nicht beantwortet ist, weiß niemand, wogegen die Anwendung eigentlich geschützt ist.

## 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. In Code-Reviews sehe ich die beiden regelmäßig verwechselt, 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 eingehängt wird sie heute an anderer Stelle.

```ts
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 Projekten regelmäßig jemand hängen bleibt. Erstens: `req.headers.authorization` ist in Express immer kleingeschrieben – die HTTP-Header-Keys landen dort in Kleinbuchstaben, 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:

```mermaid
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. Es lohnt sich, den Inhalt bewusst kurz zu halten. Hinein gehört:

- die Identität – `user` oder `viewer`, aus dem Token ermittelt
- die Berechtigungen dieses Nutzers – `roles` oder `scopes`
- 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](https://www.mikebild.dev/de/blog/graphql-paginierung-und-connections/) 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.

```ts
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.

```graphql
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.

```ts
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 im Team 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.

```ts
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:

```mermaid
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 ist auch die Ausgangsfrage beantwortet. "Wir prüfen das im Resolver" reicht nicht als Architekturentscheidung. Die Identität kommt einmal pro Request in den Context. Der Resolver ruft für die konkrete Operation eine zentrale Policy oder Fachfunktion auf; dort fällt die datenabhängige Berechtigungsentscheidung. Directives und ausgeblendete Buttons können das sichtbar machen, ersetzen diese Prüfung aber nicht.

In den Projekten, in denen Auth am Ende ruhig lief, war sie nie eine Schema-Frage, sondern eine Frage der Lebenszyklen und der Verantwortlichkeiten. Der context weiß, wer du bist. Die Business-Logik weiß, was du darfst. Bleiben die beiden getrennt, wird der Rest einfach.

## Weiterführende Quellen

- [Grounding-Repository MikeBild/introduction-graphql](https://github.com/MikeBild/introduction-graphql) – `introduction-graphql/auth.md` und `recipes/auth/`
- [graphql.org – Authorization](https://graphql.org/learn/authorization/) – warum Autorisierung in die Business-Logik-Schicht gehört
- [Apollo Server – Authentication and authorization](https://www.apollographql.com/docs/apollo-server/security/authentication) – GraphQLError-Codes, `UNAUTHENTICATED` vs. `FORBIDDEN`
- [Apollo Server – The context object](https://www.apollographql.com/docs/apollo-server/data/context) – context und contextValue, einmal pro Request
