BlogTechnik

OAuth für Remote-MCP-Server: was wir beim Livegang gelernt haben

Erfahrungen aus einem produktiven MCP-Server, der Supabase Auth als Autorisierungsserver nutzt: was die Spezifikation verlangt, was wir falsch gemacht haben und was wir noch nicht getestet haben.

Geschrieben von
Veröffentlicht
Aktualisiert
Länge
12 Min. Lesezeit

Die kurze Antwort

Ein Remote-MCP-Server ist eine durch OAuth geschützte Ressource. Ohne Token antwortet er mit 401 und verweist auf seine RFC-9728-Metadaten; der Client folgt dem zum Autorisierungsserver, registriert sich, meldet die Person mit PKCE und dem Parameter resource an und kommt mit einem Bearer-Token zurück, das für die URL des Servers ausgestellt wurde. Wir betreiben das produktiv mit Supabase Auth. Die meiste Arbeit steckte in drei Details: ein 401 mit Scope-Hinweis und ohne Fehlercode, ein Token-Hook, der den Tokens von Supabase unsere Audience und unseren Scope hinzufügt, und dynamische Client-Registrierung, weil unser Autorisierungsserver keine Client-ID-Metadaten-Dokumente anbietet.

Wer macht was, wenn ein MCP-Server OAuth nutzt?

Der MCP-Server hat die kleinste Aufgabe: Er akzeptiert oder lehnt Bearer-Tokens ab. Die MCP-Autorisierungsspezifikation (Version 2025-11-25) macht ihn zu einem OAuth-2.1-Ressourcenserver und den Assistenten zu einem OAuth-Client und erlaubt, dass der Autorisierungsserver, der die Person anmeldet und Tokens ausstellt, ein eigener Dienst ist.

Bei uns ist er getrennt. Der OAuth-2.1-Server von Supabase Auth stellt jedes Token aus, unsere Website liefert die Anmelde- und Zustimmungsseite, und der Endpunkt unter https://mcp.scorestarling.com/mcp verifiziert nur Tokens. Er läuft mit Streamable HTTP ohne MCP-Sitzungen und prüft das Token bei jeder Anfrage. ScoreStarling macht aus Aufnahmen bearbeitbare Noten, und über diesen Endpunkt erreichen Assistenten es; die Einrichtungsanleitung zeigt die Schritte, die man als Nutzer durchläuft.

Was passiert, wenn sich ein MCP-Client verbindet?

Der Client startet nur mit der URL. Zwei Antworten unseres Servers, hier mit Stand 3. Oktober 2026, sagen ihm, wo er sich anmelden soll; sechs weitere Schritte bringen ihm ein Token. Erstens bekommt eine Anfrage ohne Token ein 401, dessen WWW-Authenticate-Header die Metadaten und die anzufordernden Scopes nennt:

$ curl -si -X POST https://mcp.scorestarling.com/mcp \
    -H 'content-type: application/json' \
    -H 'accept: application/json, text/event-stream' -d '{}'
HTTP/2 401
content-type: application/json
www-authenticate: Bearer resource_metadata="https://mcp.scorestarling.com/.well-known/oauth-protected-resource/mcp", scope="openid email profile"

{"error_description": "Authentication required"}

Zweitens ruft der Client diese Metadaten ab. RFC 9728 bildet ihre Adresse, indem /.well-known/oauth-protected-resource zwischen Host und Pfad eingefügt wird, und verlangt, dass resource mit der URL identisch ist, die der Client verwendet; sonst muss der Client abbrechen. Unsere Supabase-Projektreferenz ist hier ersetzt:

{
  "resource": "https://mcp.scorestarling.com/mcp",
  "authorization_servers": ["https://<project-ref>.supabase.co/auth/v1"],
  "scopes_supported": ["openid", "email", "profile"],
  "bearer_methods_supported": ["header"]
}

Die übrigen sechs Schritte:

  1. Der Client liest die Metadaten des Autorisierungsservers. Der Issuer von Supabase hat einen Pfad, daher setzt RFC 8414 das Well-known-Segment davor: https://<project-ref>.supabase.co/.well-known/oauth-authorization-server/auth/v1. Der Client prüft, dass issuer genau übereinstimmt und dass code_challenge_methods_supported den Wert S256 enthält; die MCP-Spezifikation sagt, dass er abbrechen soll, wenn dieses Feld fehlt.

  2. Er sendet seine eigenen Metadaten per POST an den bekanntgegebenen registration_endpoint (dynamische Client-Registrierung, RFC 7591) und bekommt eine client_id. Unser Testclient registriert sich als öffentlicher Client ohne Secret; laut der Dokumentation von Anthropic tut Claude das auch.

  3. Er öffnet den Browser am Autorisierungs-Endpunkt mit einer PKCE-S256-Challenge, dem Scope aus dem 401 und resource=https://mcp.scorestarling.com/mcp (RFC 8707). Supabase leitet den Browser an unsere Zustimmungsseite weiter, die die Person bei Bedarf anmeldet und sie bittet, den Client zu genehmigen oder abzulehnen.

  4. Er tauscht den Code mit seinem PKCE-Verifier und demselben resource ein und erhält ein Access-Token, das eine Stunde gilt, sowie ein Refresh-Token.

  5. Er sendet bei jeder MCP-Anfrage Authorization: Bearer …. Wir prüfen die Signatur gegen die veröffentlichten Schlüssel des Issuers, außerdem Issuer, Audience, Ablaufzeit und den Scope für Schreibzugriff und danach, dass das Konto nicht deaktiviert wurde.

  6. Läuft das Token ab oder antworten wir mit 401 und invalid_token, erneuert er es, wieder mit resource, und erhält ein neues Access-Token und Refresh-Token.

Clients müssen resource bei den Autorisierungs- und Token-Anfragen senden, auch wenn der Autorisierungsserver es ignoriert. Die Dokumentation zum OAuth-Ablauf von Supabase erwähnt den Parameter nicht (geprüft am 3. Oktober 2026). Supabase hat ihn in allen unseren Live-Läufen akzeptiert, und die Audience in unseren Tokens stammt aus einem Hook.

Was sollte der WWW-Authenticate-Header des 401 enthalten?

Wenn kein Token gesendet wurde: wo die Metadaten liegen, welche Scopes anzufordern sind, und keinen Fehlercode. RFC 6750 §3.1 sagt, dass eine Anfrage ganz ohne Authentifizierungsinformationen keinen Fehlercode bekommen soll. invalid_token ist für ein Token gedacht, das gesendet wurde und die Prüfung nicht bestanden hat, und sagt dem Client, dass er ein neues holen und es erneut versuchen darf. Die MCP-Spezifikation ergänzt, dass das 401 einen scope-Hinweis tragen soll, den Clients als die anzufordernden Scopes behandeln müssen.

Beim Schreiben unserer Einrichtungsanleitung haben wir gemerkt, dass unser eigener Server das falsch machte. Im MCP Python SDK 1.30.0 beantwortet RequireAuthMiddleware jede nicht authentifizierte Anfrage mit error="invalid_token" und ohne Scope, sodass einem Client, der nie ein Token gesendet hatte, gesagt wurde, sein Token sei ungültig. Eine kleine ASGI-Middleware um die MCP-Route schreibt jetzt nur die 401-Antworten des SDK um; das SDK entscheidet weiterhin, wer hineinkommt, und seine 403-Antwort insufficient_scope bleibt unberührt:

# No token, before the fix (the SDK's default; wrapped for reading)
WWW-Authenticate: Bearer error="invalid_token",
    error_description="Authentication required",
    resource_metadata="…/.well-known/oauth-protected-resource/mcp"

# No token, now
WWW-Authenticate: Bearer
    resource_metadata="…/.well-known/oauth-protected-resource/mcp",
    scope="openid email profile"

# Rejected token: expired, wrong audience, malformed or account turned off
WWW-Authenticate: Bearer error="invalid_token",
    error_description="Invalid or expired access token",
    resource_metadata="…/.well-known/oauth-protected-resource/mcp",
    scope="openid email profile"

Der Hinweis nennt openid email profile, die Scopes, die Supabase vergeben kann, passend zu scopes_supported. Unsere Deployment-Prüfung schlägt jetzt fehl, wenn eine Challenge ohne Token einen Fehlercode trägt. Der Fix war am 2. Oktober 2026 innerhalb von vier Minuten nach dem Merge in Produktion. Die Dokumentation zu Konnektoren von Anthropic ergänzt zwei Regeln für Claude: Die Anmeldung beginnt nur bei einem 401 (WWW-Authenticate bei einem 200 wird ignoriert), und von authorization_servers wird nur der erste Eintrag verwendet.

Wie bekommst du eine MCP-Audience und einen Scope in die Tokens von Supabase?

Mit einem Custom Access Token Hook. Die MCP-Spezifikation sagt, dass ein Server nur Tokens akzeptieren darf, die für ihn ausgestellt wurden, was in der Praxis heißt, aud zu prüfen, und wir wollten einen eigenen Scope für Schreibzugriff; die Dokumentation von Supabase sagt, dass sein OAuth-Server keine eigenen Scopes unterstützt (geprüft am 3. Oktober 2026). Der Hook ist eine Postgres-Funktion, die Supabase Auth vor dem Ausstellen eines Tokens ausführt und die Claims umschreiben kann. Unserer, leicht vereinfacht aus unserer Migration:

create or replace function scorestarling.access_token_hook(event jsonb)
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
declare claims jsonb := event->'claims';
begin
  -- Tokens issued to an OAuth client carry its client_id; website sessions don't.
  if coalesce(claims->>'client_id', '') <> '' then
    claims := jsonb_set(claims, '{aud}', '"https://mcp.scorestarling.com/mcp"');
    claims := jsonb_set(claims, '{scope}', '"openid email profile scorestarling:write"');
  end if;
  return jsonb_build_object('claims', claims);
end;
$$;
grant usage on schema scorestarling to supabase_auth_admin;
grant execute on function scorestarling.access_token_hook(jsonb) to supabase_auth_admin;
revoke all on function scorestarling.access_token_hook(jsonb) from public, anon, authenticated;

Die MCP-Seite prüft diese Claims mit PyJWT und wendet dann zwei eigene Regeln an:

claims = jwt.decode(token, key, algorithms=["RS256", "ES256"],
                    issuer=ISSUER, audience=ACCEPTED_AUDIENCES,
                    options={"require": ["exp", "iat", "iss", "aud", "sub"]})
if "scorestarling:write" not in claims.get("scope", "").split():
    return None  # the client gets 401 invalid_token
# then: has this account been turned off?

ACCEPTED_AUDIENCES ist die MCP-URL, bei einem Umzug zusätzlich die alte. Der Test auf die Client-ID hält die Anmeldung der Website selbst heraus: Ein Browser-Sitzungs-Token aus demselben Supabase-Projekt hat denselben Issuer und dieselben Schlüssel, aber keine client_id, und bekommt deshalb nie die MCP-Audience.

Der Hook hat zwei Nachteile. Er setzt eine feste Audience, statt dem resource des Clients zu folgen, was funktioniert, solange ein Autorisierungsserver einen MCP-Server bedient. Und unsere Zustimmungsseite kann nur die Scopes zeigen, die der Client angefordert hat, sodass die Genehmigung des Clients den Scope für Schreibzugriff erteilt.

Ein offener Bericht, supabase/auth#2820 (eröffnet am 20. September 2026; bis zum 3. Oktober keine Antworten), sagt, dass die Anfrage von Supabase nach den Autorisierungsdetails bei öffentlichen Clients, offline_access oder resource ein 400 zurückgibt. Nichts davon ließ sich bei uns reproduzieren; im Supabase-Auth-Quellcode, den wir durchgesehen haben, erscheint dieses 400 nur bei einer Autorisierung, die nicht mehr aussteht (pending), etwa einer, die zweimal abgerufen wurde.

Dynamische Client-Registrierung oder Client-ID-Metadaten-Dokumente?

Nimm, was dein Autorisierungsserver anbietet; bei Supabase ist das heute die dynamische Client-Registrierung (DCR). Die MCP-Spezifikation 2025-11-25 empfiehlt Client-ID-Metadaten-Dokumente (CIMD), wenn Client und Server einander noch nie begegnet sind, und behält DCR aus Gründen der Abwärtskompatibilität bei. Clients sollten zuerst eine vorab vergebene Client-ID versuchen, dann CIMD, dann DCR und erst danach die Person fragen.

Wie ein MCP-Client an eine Client-ID kommt
AnsatzWie sich der Client ausweistSignal in den Metadaten des ServersOption im Formular von ClaudeUnser Server, 3. Oktober 2026
Vorab-RegistrierungEine vorab erstellte Client-ID, von Hand eingegeben oder fest eingebautKeine nötig„Use your own OAuth client“ (Eigenen OAuth-Client verwenden)Nicht angeboten
Client-ID-Metadaten-DokumentSeine client_id ist eine HTTPS-URL zu einer JSON-Datei, die der Client hostet und die der Server abruftclient_id_metadata_document_supported„Use Claude’s published identity“ (Veröffentlichte Identität von Claude verwenden), empfohlenNicht bekanntgegeben
Dynamische Client-RegistrierungEr sendet seine Metadaten per POST an den Registrierungs-Endpunkt und bekommt eine client_idregistration_endpoint„Register automatically“Bekanntgegeben

Die Metadaten von Supabase, am 3. Oktober 2026 erneut gelesen, führen einen Registrierungs-Endpunkt, S256 und die Token-Endpunkt-Methode none auf und kein CIMD-Flag; laut der MCP-Anleitung von Supabase schaltest du die dynamische Registrierung im Dashboard ein.

Das entscheidet, was man im Formular für benutzerdefinierte Konnektoren in Claude auswählt. Die Dokumentation von Anthropic beschreibt die empfohlene Option, die veröffentlichte Identität von Claude, als ein CIMD, das Anthropic hostet und das der Server unterstützen muss. Deshalb verlangt unsere Anleitung „Sign in now“, da keins unserer Tools ohne Konto funktioniert, und „Register automatically“. Laut den Entwicklerdokumenten von Anthropic fällt Claude auf DCR zurück, wenn CIMD nicht bekanntgegeben wird (CIMD braucht außerdem none in token_endpoint_auth_methods_supported); wir haben das Formular nicht mit der belassenen Voreinstellung getestet, deshalb verlangen wir die ausdrückliche Wahl. Die Client-Dokumentation, die wir am 2. Oktober 2026 gelesen haben, beschreibt DCR für den Entwicklermodus von ChatGPT, Cursor, VS Code und Gemini CLI.

DCR hat einen Preis: Anthropic merkt an, dass Claude bei jeder neuen Verbindung einen neuen Client registriert, und Supabase warnt, dass dynamische Registrierung es jedem MCP-Client erlaubt, sich bei deinem Projekt zu registrieren. Die Registrierung allein gewährt aber nichts. Eine Person muss sich weiterhin anmelden und den Client genehmigen, und der Verifier prüft das Konto bei jeder Anfrage. Weil sich jeder Client unter jedem Namen registrieren kann, zeigt unsere Zustimmungsseite seit dem 6. Oktober 2026, wohin die Genehmigung die Person schickt, und warnt, wenn sich eine App ChatGPT oder Claude nennt, aber nicht zur Adresse dieses Assistenten zurückkehrt.

Bleiben MCP-Clients angemeldet, wenn das Access-Token abläuft?

Ja. Access-Tokens gelten eine Stunde, und Clients erneuern sie mit Refresh-Tokens. Bevor wir uns darauf verlassen haben, haben wir den Quellcode von Supabase Auth gelesen (master, Stand 22. September 2026):

  • jeder Code-Austausch liefert ein Refresh-Token, mit oder ohne offline_access;
  • ein Refresh muss vom eigenen Client der Sitzung kommen, rotiert das Refresh-Token und behält den Claim client_id;
  • der Hook läuft auch bei Refreshes (authentication_method ist token_refresh), unsere Audience und unser Scope kommen also jedes Mal zurück.

Ein Live-Lauf am 2. Oktober 2026 hat das bestätigt: Das Refresh-Token wurde rotiert, und das erneuerte Access-Token funktionierte. Laut der Dokumentation von Anthropic erneuert Claude nach einem 401 und bis zu fünf Minuten vor Ablauf und fügt offline_access hinzu, wenn der Autorisierungsserver es in scopes_supported auflistet. Die Metadaten von Supabase führen es auf, und unsere Prüfung, die auch offline_access angefordert hat, war erfolgreich.

Sitzungen enden trotzdem, wenn:

  • das Konto deaktiviert wird oder der Nutzer gelöscht oder gesperrt wird (in unserem Live-Lauf hat das Deaktivieren des Kontos die Browser-Sitzung und beide MCP-Tokens abgelehnt);
  • ein altes Refresh-Token nach dem Wiederverwendungsintervall von Supabase, standardmäßig 10 Sekunden, erneut verwendet wird, was die ganze Sitzung widerruft;
  • eine zeitliche Begrenzung (Time-box), ein Inaktivitäts-Timeout oder „Single session per user“ (eine Sitzung pro Nutzer) eingeschaltet wird. Lass Letzteres aus, sonst beendet eine Anmeldung auf der Website die Sitzung des Assistenten;
  • die Person sich mit dem Standard-logout von Supabase von der Website abmeldet, was jede Sitzung des Kontos beendet, auch die des Assistenten. Bei uns war das bis zum 6. Oktober 2026 so, und das war wahrscheinlich der Grund, warum eine ChatGPT-Verbindung ihr Token nicht mehr erneuern konnte; „Abmelden“ sendet jetzt scope=local und beendet nur die Sitzung dieses Browsers.

Supabase gibt keinen Widerrufs-Endpunkt bekannt (geprüft am 3. Oktober 2026), deshalb sperren wir den Zugriff, indem wir das Konto deaktivieren.

Warum ist ein abschließender Schrägstrich im Issuer wichtig?

Weil Issuer Zeichen für Zeichen verglichen werden. RFC 8414 verlangt, dass der issuer in den Metadaten mit dem identisch ist, über den der Client sie gefunden hat, und RFC 9728 verlangt dasselbe von resource. Für einen strengen Client sind https://auth.example.com und https://auth.example.com/ verschiedene Issuer.

Das MCP Python SDK speichert den Issuer als pydantic-AnyHttpUrl, und pydantic 2.13.5, die Version in unserer Lockfile, macht aus https://auth.example.com ein https://auth.example.com/. Metadaten, die daraus gebaut werden, nennen einen Issuer, den der Autorisierungsserver nie veröffentlicht hat. Deshalb schreibt unser Server seine eigenen Protected-Resource-Metadaten mit dem Issuer genau so, wie er konfiguriert ist. Der Issuer von Supabase endet in einem Pfad, den pydantic unangetastet lässt; uns hat das in Produktion deshalb nie getroffen. Bei einem Issuer, der nur aus dem Origin besteht, würde es uns treffen. Unsere Deployment-Prüfung schlägt fehl, wenn der issuer des Autorisierungsservers nicht genau mit unserem übereinstimmt.

Was wir geprüft haben und was nicht

Unsere ersten Belege stammen von einem skriptgesteuerten Client, der der MCP-Spezifikation folgt. Am 2. Oktober 2026 deckte ein Lauf gegen die Produktion Folgendes ab: das 401 ohne Token, die dynamische Registrierung eines öffentlichen Clients, Zustimmung und Code-Austausch mit resource, einen Refresh mit Rotation, tools/list mit beiden Tokens und das Deaktivieren des Kontos, wodurch beide Tokens und die Browser-Sitzung abgelehnt wurden. Ein zweiter Lauf hat auch offline_access angefordert, und beide waren erfolgreich. Dieser Lauf verwendete noch unsere frühere Adresse; für den Umzug auf mcp.scorestarling.com gibt es einen eigenen Artikel.

Seitdem haben sich echte Clients verbunden. Die App von ChatGPT im Entwicklermodus hat sich am 3. Oktober 2026 über mcp.scorestarling.com angemeldet und ein Tool aufgerufen, und Claude hat sich am 6. Oktober verbunden und Tools ausgeführt. Noch nicht geprüft:

  • ein Refresh, nachdem ein Token wirklich abgelaufen ist: Der skriptgesteuerte Refresh lief direkt nach der Anmeldung, und wir haben noch keinen von einem echten Client nachverfolgt;
  • die gehosteten Sitzungseinstellungen von Supabase, die wir nicht zurückgelesen haben.

Quellen

  1. Authorization, MCP specification version 2025-11-25, Model Context Protocol
  2. RFC 9728: OAuth 2.0 Protected Resource Metadata, IETF
  3. RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage, §3.1, IETF
  4. RFC 8707: Resource Indicators for OAuth 2.0, IETF
  5. RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol, IETF
  6. RFC 8414: OAuth 2.0 Authorization Server Metadata, IETF
  7. OAuth Client ID Metadata Document (Internet-Draft), IETF OAuth Working Group
  8. Authentication for connectors, Anthropic
  9. Add a connector that isn’t in the directory, Anthropic
  10. OAuth 2.1 Flows, Supabase
  11. Custom Access Token Hook, Supabase
  12. Model Context Protocol (MCP) Authentication, Supabase
  13. User sessions, Supabase
  14. supabase/auth issue #2820, GitHub

Fragen und Antworten

Braucht ein Remote-MCP-Server einen eigenen Autorisierungsserver?

Nein. Die MCP-Autorisierungsspezifikation erlaubt, dass der Autorisierungsserver ein eigener Dienst ist. Der MCP-Server veröffentlicht RFC-9728-Metadaten, die den Autorisierungsserver benennen, und prüft dann die Tokens, die dieser ausstellt, einschließlich ihrer Audience. Unserer verweist auf Supabase Auth und verifiziert nur Tokens.

Soll das 401 eines MCP-Servers den Fehler invalid_token enthalten, wenn gar kein Token gesendet wurde?

Nein. RFC 6750 §3.1 sagt, dass eine Anfrage ohne Anmeldedaten keinen Fehlercode bekommen soll. Sende stattdessen resource_metadata und einen scope-Hinweis und spar dir invalid_token für ein Token auf, das gesendet und abgelehnt wurde; dann weiß der Client, dass er das Token erneuern soll.

Kann Supabase Auth eigene Scopes für einen MCP-Server ausstellen?

Stand 3. Oktober 2026 nicht: Laut der OAuth-2.1-Dokumentation von Supabase werden eigene Scopes nicht unterstützt. Wir fordern openid email profile an und ergänzen unseren eigenen Scope und die MCP-Audience in einem Custom Access Token Hook, der auch bei jedem Refresh läuft.

Was sollte man im Formular für benutzerdefinierte Konnektoren in Claude wählen, wenn der Server CIMD nicht unterstützt?

Unter „OAuth client“ (OAuth-Client) die Option „Register automatically“ (Automatisch registrieren), die die dynamische Client-Registrierung nutzt, und unter „Authentication“ (Authentifizierung) „Sign in now“ (Jetzt anmelden), wenn jedes Tool ein Konto braucht. Die von Claude empfohlene veröffentlichte Identität ist ein Client-ID-Metadaten-Dokument, und der Server muss es unterstützen. Unsere Schritte für Claude zeigen beide Optionen.

Brauche ich den Scope offline_access, um von Supabase Refresh-Tokens zu bekommen?

In unseren Tests nicht. Supabase hat bei jedem Code-Austausch auch ohne ihn ein Refresh-Token zurückgegeben, im Quellcode und in Live-Läufen am 2. Oktober 2026. Manche Clients fordern ihn trotzdem an; laut der Dokumentation von Anthropic tut Claude das, wenn der Autorisierungsserver ihn auflistet, und auch unsere Prüfung damit war erfolgreich.

Bring jede Musik mit und geh mit einer Partitur