MCP-Authentifizierungsmuster: OAuth, Tokens und wann was
Die Model-Context-Protocol-Spezifikation (modelcontextprotocol.io) definiert Transporte und Primitive. Sie definiert Authentifizierung nicht. Das ist richtig, denn Authentifizierung ist eine Transport- und Deployment-Sorge, keine Protokoll-Sorge. Es ist auch die Quelle des häufigsten Produktionsfehlers, den ich sehe: ein MCP-Server geht ohne Auth ans Netz und exponiert Tools, die Zustand verändern. In DACH-Projekten kommt dazu, dass DSGVO Art. 32 ohnehin angemessene technische Maßnahmen verlangt, sobald personenbezogene Daten im Spiel sind.
Dieser Artikel verankert sich am Pillar MCP-Server-Entwicklungsservice.
TL;DR
- Read-only auf öffentlichen Daten mit Rate-Limit ist der einzige legitime Anonym-Fall.
- Scoped API-Tokens mit gehashter Speicherung und kurzer TTL decken B2B und Headless-Flows ab.
- OAuth 2.1 mit PKCE deckt Konsumenten-Assistenten ab, die für eingeloggte Nutzer handeln.
- Rate-Limiting hängt am Principal; mutierende Tools bekommen ein engeres Bucket.
- Jedes Auth-Ereignis wird geloggt: ausgegeben, verwendet, widerrufen, gescheitert.
Der Entscheidungsbaum
Ich gehe vor jeder Auth-Wahl dieselben fünf Fragen durch:
- Verändert irgendein Tool Zustand? Wenn ja, anonym ist vom Tisch.
- Handelt der Agent im Namen eines konkreten menschlichen Endnutzers? Wenn ja, OAuth.
- Handelt der Agent als B2B-Integration ohne menschlichen Endnutzer? Wenn ja, scoped API-Token.
- Ist die Datenoberfläche im öffentlichen Internet erreichbar? Wenn ja, Rate-Limiting ist Pflicht.
- Mischen sich mutierende und Read-only-Tools im selben Server? Wenn ja, Auth pro Tool, nicht pro Server.
Der Entscheidungsbaum mappt auf drei Muster:
| Muster | Wann nutzen | Umsetzung |
|---|---|---|
| Anonym + IP-Rate-Limit | Öffentlicher Katalog, Read-only, begrenzte Last | Worker prüft nur das IP-Bucket |
| Scoped API-Token | B2B-Integration, Headless-Agenten-Runtime, kein menschlicher Endnutzer | JWT mit Claims, gehashte Speicherung, Rotation |
| OAuth 2.1 + PKCE | Konsumenten-Agent für eingeloggten Nutzer | Standard-Authorization-Code-Flow |
Die Muster schließen sich nicht aus. Ein realer Produktionsserver fährt oft alle drei, mit jedem Tool getaggt mit dem nötigen Scope.
Muster eins: anonymer Read-only mit Rate-Limit
Ein Katalog-Browse-Tool, das /wp-json/wc/v3/products?stock_status=instock kapselt, exponiert dieselben Daten, die die öffentliche Website ohnehin ausliefert. Auth davorzusetzen verbessert die Sicherheitslage nicht; es senkt nur Erreichbarkeit. Die legitime Bedrohung ist Last: eine Agenten-Schleife oder ein Wettbewerber-Scrape kann den WooCommerce-Origin hämmern.
Die Umsetzung:
async function checkAnonymousRateLimit(request: Request, env: Env): Promise<boolean> {
const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
const key = `rl:anon:${ip}`;
const count = Number((await env.RATE_LIMIT.get(key)) ?? "0");
if (count >= 60) return false;
await env.RATE_LIMIT.put(key, String(count + 1), { expirationTtl: 60 });
return true;
}
60 Requests pro Minute pro IP ist mein Default. Der KV-basierte Zähler ist approximativ (eventually consistent bei KV-Writes); für engere Limits sind Durable Objects oder Cloudflares natives Rate-Limiting-Binding das richtige Werkzeug.
Was dieses Muster nicht abdeckt: jedes Tool, das nutzerspezifische Daten zurückgibt, jedes Tool, das Zustand verändert, jedes Tool, das Bestand für nicht öffentliche Produkte preisgibt. Diese brauchen einen echten Principal.
Muster zwei: scoped API-Tokens für B2B
Der B2B-Fall: eine Partner-Integration liefert einen Agenten aus, der deinen MCP-Server im Namen der Partner-Organisation aufruft, nicht im Namen eines konkreten menschlichen Endnutzers. Beispiele sind ein Inventar-Sync-Agent bei einem Großhändler, eine Marktplatz-Integration bei einem Aggregator, ein Analytik-Agent, der Bestelltrends an ein BI-Tool meldet.
Der Ablauf:
- Eine Admin-Person im WordPress-Backend legt ein Token an, mit Name, Scope-Set (
catalogue:read,inventory:read,orders:write) und Ablauf (Default 90 Tage). - Das Token wird der Admin-Person einmal angezeigt, dann als SHA-256-Hash plus Scope-Set plus Ablauf gespeichert.
- Der Partner trägt das Token in seinem MCP-Client als
Authorization: Bearer <token>-Header ein. - Der Worker hasht das eingehende Token und schlägt den Hash in KV nach. Stimmt der Hash, liegt der Ablauf in der Zukunft und ist der für das Tool nötige Scope im Scope-Set, läuft der Aufruf weiter.
async function verifyApiToken(authHeader: string | null, env: Env): Promise<TokenContext | null> {
if (!authHeader?.startsWith("Bearer ")) return null;
const token = authHeader.slice(7);
const hash = await sha256(token);
const record = await env.TOKENS.get(`tok:${hash}`, "json") as TokenRecord | null;
if (!record) return null;
if (record.expiresAt < Date.now()) return null;
return { tokenId: record.id, scopes: record.scopes, principal: record.principal };
}
function requireScope(ctx: TokenContext, scope: string): void {
if (!ctx.scopes.includes(scope)) {
throw new McpError("forbidden", `Tool requires scope ${scope}`);
}
}
Zwei operative Gewohnheiten halten dieses Muster sauber:
Rotation, keine Ewigkeit. Ein Token, das nie abläuft, ist ein Token, das in sechs Monaten in einem öffentlichen GitHub-Repo landet. 90 Tage Default mit Renewal-Flow, der altes und neues Token sieben Tage überlappt, ist das Muster, das echte Partner überlebt.
Gehashte Speicherung, kein Klartext. Wird dein KV exfiltriert, sind die Hashes ohne Originaltoken nutzlos. Hattest du Klartext-Tokens, braucht jede Partner-Integration sofortige Rotation. Der Kostenunterschied bei der Ausgabe ist ein einziger sha256-Aufruf.
Muster drei: OAuth 2.1 mit PKCE für delegierte Agenten
Der Konsumentenfall: ein Nutzer öffnet Claude Desktop, verbindet sein Konto auf deinem Shop per OAuth und bittet den Agenten, “zeige meine letzte Bestellung”. Der Agent muss deinen MCP-Server jetzt mit Credentials anrufen, die “ich handle für Nutzer 4231” sagen.
OAuth 2.1 (draft-ietf-oauth-v2-1) ist das konsolidierte Profil. PKCE (RFC 7636) ist Pflicht für öffentliche Clients (das schließt Desktop-Assistenten ohne vertraulichen serverseitigen Anteil ein).
Der Flow in fünf Etappen:
- Der MCP-Host öffnet einen Browser auf deinen Authorization-Endpoint mit
response_type=code,code_challenge=<S256-Hash des Verifier>und den geforderten Scopes. - Der Nutzer loggt sich auf deiner WordPress-Site (oder bei deinem Auth-Provider) ein und genehmigt die Scopes.
- Dein Authorization-Endpoint redirected zurück an den Host mit einem einmaligen Authorization-Code.
- Der Host tauscht den Code plus den ursprünglichen
code_verifieran deinem Token-Endpoint gegen ein Access-Token (kurze TTL, 1 Stunde) und ein Refresh-Token (längere TTL, 30 Tage). - Der Host ruft den MCP-Server mit
Authorization: Bearer <access_token>auf. Der Worker prüft Signatur, Ablauf und Scope-Set des Tokens gegen das aufgerufene Tool.
Das Access-Token ist ein signiertes JWT. Der Worker prüft es mit der Web-Crypto-API (Cloudflare-Workers-Referenz) ohne externe Bibliothek:
async function verifyJwt(token: string, env: Env): Promise<JwtClaims | null> {
const [headerB64, payloadB64, sigB64] = token.split(".");
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const sig = base64UrlDecode(sigB64);
const valid = await crypto.subtle.verify("RS256", env.PUBLIC_KEY, sig, data);
if (!valid) return null;
const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
if (payload.exp * 1000 < Date.now()) return null;
return payload as JwtClaims;
}
Die Scopes im JWT spiegeln die Scopes aus dem Scoped-Token-Muster: catalogue:read, orders:read, orders:write. Die WordPress-Nutzer-ID reitet im sub-Claim, sodass ein orders:read-Aufruf nur die Bestellungen dieses Nutzers liefert.
Muster im selben Server mischen
Ein realer WooCommerce-MCP-Server exponiert in der Regel:
catalogue.listundproduct.detail: anonym + IP-Rate-Limit.inventory.check(für Partner): scoped Token mitinventory:read.order.status(für eingeloggten Nutzer): OAuth mitorders:read.order.intent(für eingeloggten Nutzer): OAuth mitorders:write, plus engerem Rate-Limit.
Die Pre-Dispatch-Logik im Worker geht die Muster durch:
async function authenticate(request: Request, toolName: string, env: Env): Promise<Principal> {
const requirement = TOOL_AUTH_REQUIREMENTS[toolName];
if (requirement === "anonymous") {
if (!await checkAnonymousRateLimit(request, env)) throw new McpError("rate_limit");
return { kind: "anonymous" };
}
const auth = request.headers.get("Authorization");
if (requirement === "api_token") {
const ctx = await verifyApiToken(auth, env);
if (!ctx) throw new McpError("unauthorized");
return { kind: "api_token", ctx };
}
if (requirement === "oauth") {
const claims = auth?.startsWith("Bearer ") ? await verifyJwt(auth.slice(7), env) : null;
if (!claims) throw new McpError("unauthorized");
return { kind: "oauth", claims };
}
throw new Error(`Unknown auth requirement for ${toolName}`);
}
Die TOOL_AUTH_REQUIREMENTS-Map ist die einzige Wahrheit darüber, welches Tool welchen Auth-Modus braucht. Kein Tool wird ohne expliziten Eintrag hinzugefügt.
Rate-Limit pro Principal
Anonymer Verkehr bekommt ein Bucket pro IP. Token-authentifizierter Verkehr bekommt ein Bucket pro Token. OAuth-Verkehr bekommt ein Bucket pro Nutzer. Mutierende Tools bekommen unabhängig vom Principal eine engere Decke als Lese-Tools.
Für die WooCommerce-Form sind meine Default-Buckets:
| Tool-Kategorie | Anonym | Token | OAuth |
|---|---|---|---|
catalogue.* (Lesen) | 60 / Minute / IP | 600 / Minute / Token | 120 / Minute / Nutzer |
inventory.* (Lesen) | nicht erlaubt | 300 / Minute / Token | nicht erlaubt |
order.status (Lesen) | nicht erlaubt | 60 / Minute / Token | 60 / Minute / Nutzer |
order.intent (Schreiben) | nicht erlaubt | 30 / Minute / Token | 10 / Minute / Nutzer |
Die Zahlen sind Startwerte; die richtigen Werte liefert Beobachtung von echtem Verkehr über zwei Wochen plus Tuning. Die Struktur zählt.
Auth-Ereignisse loggen
Jedes auth-relevante Ereignis landet im Log:
- Token ausgegeben. Admin-Nutzer, Ziel-Principal, Scopes, Ablauf.
- Token verwendet. Token-ID, Tool-Name, Principal, Latenz, Erfolg/Fehler.
- Token widerrufen. Token-ID, wer hat widerrufen, warum.
- Token gescheitert. Grund (abgelaufen, Scope fehlt, Hash-Mismatch), IP, User-Agent.
- OAuth-Code getauscht. Nutzer-ID, gewährte Scopes, ausgegebenes Refresh-Token.
- OAuth-Refresh. Nutzer-ID, neues Access-Token ausgegeben, altes Token verdrängt.
Die Logs gehen via Cloudflare Logpush in einen Langzeit-Store. Eine Dashboard-Abfrage, die “Tokens, die in den letzten 24 Stunden genutzt wurden, aber in den 90 Tagen davor nicht”, überwacht, fängt wahrscheinlichen Token-Diebstahl ab. Eine Abfrage auf “fehlgeschlagene Token-Verifikationen pro IP” fängt Credential-Stuffing-Versuche ab.
Wo das im Cluster sitzt
Dieser Artikel deckt die Auth-Oberfläche ab. Für den Implementierungs-Durchstich siehe MCP-Server für WooCommerce aufbauen. Für typisierte Tool-Definitionen siehe typisierte Katalog-Tools mit Zod für MCP. Für die Protokoll-Entscheidung siehe MCP vs REST. Für den Migrationspfad aus einer bestehenden API siehe bestehende WordPress-API auf MCP migrieren. Der Pillar ist MCP-Server-Entwicklung.
Preisgestaltung individuell, weil der Auth-Umfang davon abhängt, welche Muster deine Umgebung verlangt; ein Anonym-only-Read-Server ist ein anderer Auftrag als eine vollständige OAuth-Ausgabe-Oberfläche.
