Zum Inhalt springen

Sicherheitsgrundlagen

Supabase "invalid claim: missing sub claim": niemand ist angemeldet

"invalid claim: missing sub claim" heißt: Supabase Auth bekam ein echtes Token ohne Nutzer darin, deinen Projektschlüssel statt einer Sitzung.

Vlad Tkachenko11 Min. Lesezeit
Ein Ausweis am Schlüsselband mit echtem Siegel und leerer Namenszeile, vor zwei Ausweisen mit Namen und Gesicht.

Kurz gesagt

  • "invalid claim: missing sub claim" ist Supabase Auth, das mit 403 und dem Code bad_jwt antwortet. Es wurde gefragt, wer da aufruft, das Token war echt, und es nannte niemanden.
  • Die eigene Fehlerseite von Supabase nennt die häufige Ursache: ein API-Schlüssel, geschickt an der Stelle, an die das Zugriffstoken eines angemeldeten Nutzers gehört. Ein Schlüssel sagt, welche App aufruft, und enthält keine Person.
  • Ruf die Funktion erst auf, wenn jemand angemeldet ist, und lass sie mit 401 antworten, wenn niemand angemeldet ist. Den geheimen Schlüssel einzusetzen ändert nichts, denn auch er nennt niemanden.

Deine App hat eine Supabase Edge Function, die etwas für die Person erledigt, die sie benutzt, etwa ihre Einstellungen speichern oder in ihrem Auftrag OpenAI aufrufen. Beim Testen hat sie funktioniert. Dann füllt sich das Log der Funktion mit dieser Zeile, und die App zeigt einen Fehler oder gar nichts:

AuthApiError: invalid claim: missing sub claim

Such nach "invalid claim: missing sub claim", und man rät dir, deine Schlüssel neu zu erzeugen, Verify JWT abzuschalten oder den service_role-Schlüssel einzusetzen. Jeder dieser Ratschläge behandelt das Token als kaputt. Das Token war in Ordnung. Supabase hat seine Signatur geprüft und es akzeptiert. Was ihm fehlte, war irgendein Name.

Was bedeutet "invalid claim: missing sub claim"?

Supabase Auth wurde gefragt, welcher Nutzer aufruft, fand das Token echt und fand keinen Nutzer darin eingetragen.

Die Tokens, die Supabase Auth liest, sind JWTs: drei Textblöcke, durch Punkte getrennt, und der mittlere Block sind lesbare Daten. Jeder Eintrag in diesen Daten heißt Claim. sub, kurz für Subject, ist der Claim, der die ID eines Nutzers hält, und die JWT-Referenz von Supabase beschreibt ihn als die Nutzer-ID, eine UUID.

Die Meldung steht im Quellcode von Supabase Auth selbst, in einer Funktion namens maybeLoadUserOrSession in internal/api/auth.go. Wenn diese Funktion läuft, ist die Signatur des Tokens schon geprüft und hat bestanden. Dann sucht sie nach sub, und wenn der Claim leer ist, bricht sie mit HTTP 403 ab, dem Fehlercode bad_jwt und diesem Satz. Die Antwort, die dein Code bekommt, sieht so aus:

{"code":403,"error_code":"bad_jwt","msg":"invalid claim: missing sub claim"}

Stell dir einen Empfang vor, dem eine einzige Frage gestellt wird: Welcher Besucher ist das? Du reichst einen Ausweis hin. Der Empfang prüft zuerst den Stempel darauf, und der Stempel ist echt. Dann liest er die Namenszeile, und die Namenszeile ist leer. Der Empfang hat nichts, womit er antworten könnte, also lehnt er ab, und die Ablehnung nennt die leere Zeile.

Jeder Aufruf, der eine angemeldete Person braucht, kann diese Antwort bekommen. getUser() und updateUser() sind die beiden, bei denen du sie am ehesten siehst, und auch das Hinzufügen eines zweiten Faktors oder das Verknüpfen einer weiteren Anmeldemethode läuft durch dieselbe Prüfung.

Das Siegel wird zuerst geprüft, und beide Ausweise bestehen. Die Frage, an der es scheitert, ist die zweite: Wessen Name steht darauf?

Ein Schlüssel nennt deine App. Ein Sitzungstoken nennt eine Person.

Der API-Schlüssel deines Projekts ist der Ausweis mit der leeren Namenszeile, und er wurde absichtlich so gemacht.

Die Dokumentation zu den API-Schlüsseln sagt es in einer Zeile: Ein API-Schlüssel unterscheidet laut Supabase nicht zwischen Nutzern, sondern zwischen Anwendungen. Der Schlüssel sagt Supabase, welche App aufruft. Wer diese App gerade benutzt, ist eine eigene Frage, und die beantwortet ein Token, das Supabase Auth jeder Person bei der Anmeldung ausstellt.

Die JWT-Referenz von Supabase druckt beide ab. Der anon-Schlüssel eines älteren Projekts ergibt entschlüsselt das hier, und nirgends steht ein sub:

{
  "iss": "supabase",
  "ref": "abcdefghijklmnopqrst",
  "role": "anon",
  "iat": 1640991600,
  "exp": 1640995200
}

Das Zugriffstoken einer angemeldeten Person trägt ihre ID, die Rolle authenticated und die Sitzung, zu der es gehört (hier gekürzt auf die Zeilen, auf die es ankommt):

{
  "sub": "123e4567-e89b-12d3-a456-426614174000",
  "role": "authenticated",
  "session_id": "123e4567-e89b-12d3-a456-426614174000",
  "email": "user@example.com"
}

Der service_role-Schlüssel ist wieder die erste Form, mit "role": "service_role" und weiterhin ohne sub. Die neueren Schlüssel sb_publishable_ und sb_secret_ sind gar keine JWTs, aber die Fehlerseite von Supabase zu dieser Meldung sagt, dass Auth sie erkennt und dass auch sie keinen sub-Claim tragen. Also erzeugt jeder Schlüssel deines Projekts, alt oder neu, öffentlich oder geheim, diese Meldung, wenn er Auth übergeben wird, als wäre er eine Person. Dieselbe Seite nennt den Fehler beim Namen: versehentlich einen API-Schlüssel von Supabase zu benutzen statt des Zugriffstokens von Auth.

Wenn Schlüssel und ihre Präfixe neu für dich sind, erklärt welche API-Schlüssel in dein Frontend dürfen, wie du den öffentlichen vom geheimen unterscheidest.

Wo der Schlüssel statt einer Sitzung geschickt wird

Drei Wege führen hierher, und der erste ist der, den eine Edge Function nimmt.

1. Eine Edge Function, aufgerufen von einer Seite, auf der niemand angemeldet ist. Hat supabase-js eine Sitzung, legt es das Token der Person in den Header Authorization. Hat es keine, hat es nichts anderes für diesen Platz, also schickt es bei einem älteren Schlüssel stattdessen den Schlüssel deines Projekts in diesem Header. Seine Datei fetch.ts liest sich genau so: das Sitzungstoken, wenn es eins gibt, sonst der Schlüssel.

Die Plattformprüfung Verify JWT lässt diese Anfrage durch. Ein älterer Schlüssel ist ein JWT, das dein Projekt signiert hat, und Supabase dokumentiert, dass die Prüfung auch die neueren Schlüssel annimmt. Genau diese Lücke erklärt einen geheimen Schlüssel in eine Edge Function verlegen ausführlich. Deine Funktion reicht den Header dann an Auth weiter, und das tun diese Zeilen:

const supabase = createClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SUPABASE_ANON_KEY')!,
  { global: { headers: { Authorization: req.headers.get('Authorization')! } } },
)
const { data: { user } } = await supabase.auth.getUser()

Auth liest den Schlüssel, findet die leere Namenszeile und antwortet mit 403. Gibt die Funktion diesen Fehler zurück, zeigt deine App Edge Function returned a non-2xx status code, und diese Meldung wartet im Log der Funktion.

2. Ein Aufruf, bevor die Anmeldung fertig war. Die Fehlerseite von Supabase nennt auch diesen Fall: ein Nutzer, der mit der Registrierung oder Anmeldung begonnen und sie noch nicht abgeschlossen hatte, als getUser aufgerufen wurde. Eine Form davon ist eine Registrierung, die noch auf ihre Bestätigungsmail wartet. Es gibt noch keine Sitzung, also war das, was bei Auth ankam, nicht das Token einer Person.

3. Dein eigener Server reicht Auth den falschen Wert. Eine Route, die getUser(token) mit dem Schlüssel in token aufruft, oder eine Automation in n8n, Make oder Postman, die /auth/v1/user mit dem Schlüssel im Header Authorization aufruft. Die Lösung, die die Seite von Supabase für diesen Fall nennt: sicherstellen, dass das an getUser() übergebene Token das Zugriffstoken der Person ist.

Ist niemand angemeldet, schickt die Seite denselben Schlüssel in beiden Headern. Jede Prüfung vor Auth lässt ihn durch, und erst die Frage von Auth findet die Namenszeile leer.

Wie du siehst, was deine Funktion bekommen hat

Vergleich zwei Request-Header in deinem Browser.

Öffne deine Live-App, drück F12, wähl den Tab Network und wiederhole, was fehlgeschlagen ist. Klick die Anfrage an, deren Adresse /functions/v1/ und den Namen deiner Funktion enthält, und lies ihre Request-Header:

  • Authorization enthält Bearer und danach dieselbe Zeichenkette wie apikey. Die Seite hatte keine Sitzung zu schicken, also hat sie den Schlüssel zweimal geschickt. Du bist auf Weg 1 oder 2.
  • Authorization enthält eine andere lange Zeichenkette, die mit eyJ beginnt. Das Token einer Person ist rausgegangen. Die Funktion oder dein Server hat es unterwegs verloren, also bist du auf Weg 3.
  • Es gibt keinen Header Authorization. Dann ist es nicht diese Meldung, die du siehst. Mit eingeschaltetem Verify JWT lehnt die Plattform die Anfrage mit 401 ab, bevor dein Code läuft, und der Beitrag zum non-2xx-Fehler liest das. Ist es abgeschaltet, antwortet Auth mit This endpoint requires a valid Bearer token, der ersten Zeile der Tabelle weiter unten.

Steht die Meldung stattdessen in der Konsole deines Browsers und lautet Auth session missing!, hat gar keine Anfrage Auth erreicht: supabase-js fand keine Sitzung und hörte auf, bevor es eine schickte, und das behandelt ein eigener Beitrag.

Lies die Werte dort, wo sie stehen. Kopier keinen davon in eine Website, die anbietet, ihn zu entschlüsseln: Dein Schlüssel ist ohnehin öffentlich, aber mit dem Zugriffstoken einer Person kann jeder, der es hat, als diese Person handeln, bis es abläuft.

Die Lösung: nur fragen, wenn jemand angemeldet ist

Entscheide, was die Funktion für einen Besucher tun soll, der nicht angemeldet ist, und schreib diese Entscheidung in die Funktion.

Wenn nur angemeldete Personen sie benutzen sollen, zwei Änderungen. Die Seite ruft die Funktion erst auf, wenn sich jemand angemeldet hat. Und die Funktion antwortet mit einem klaren 401, wenn getUser() keinen Nutzer findet, damit ein verirrter Aufruf von einer Seite ohne Anmeldung eine deutliche Absage bekommt statt eines Absturzes:

const { data: { user } } = await supabase.auth.getUser()
if (!user) {
  return new Response(JSON.stringify({ error: 'Sign in first' }), {
    status: 401,
    headers: { ...corsHeaders, 'Content-Type': 'application/json' },
  })
}

Behalte die CORS-Header auch an dieser Antwort, sonst versteckt der Browser sie vor deiner App, und du triffst stattdessen auf "Failed to send a request to the Edge Function". Supabase liefert außerdem einen Wrapper, withSupabase({ auth: 'user' }), der dieselbe Prüfung macht, bevor dein Handler läuft; der Beitrag zur Edge Function zeigt die Einrichtung.

Wenn auch Besucher ohne Anmeldung sie benutzen sollen, frag Auth nur auf dem Weg, der eine Person braucht. Behandle eine Absage dort als "das ist ein Besucher" und mach mit dem weiter, was ein Besucher darf, statt die Anfrage am Fehler scheitern zu lassen.

Auf deinem eigenen Server schick das Zugriffstoken der Person aus dem Browser mit, so wie es die Seite von Supabase beschreibt: Leg es in einen Header Authorization: Bearer deiner Anfrage, lies es auf dem Server und ruf supabase.auth.getUser(token) mit diesem Wert auf. Der Schlüssel deines Projekts kommt in diesen Aufruf nie hinein.

Zwei Lösungen, nach denen du schlechter dastehst

Beide werden für diesen Fehler vorgeschlagen, und nach beiden stehst du schlechter da als mit dem Fehler.

Den geheimen Schlüssel einsetzen. Das kann nicht helfen, denn service_role und sb_secret_ tragen ebenfalls kein sub, also antwortet Auth genauso. Der Schaden liegt darin, wo der Schlüssel landet. Bitte einen Builder, für einen Fehler "den Service-Schlüssel zu nehmen", und gut möglich, dass er ihn in die Seite legt, die deine Besucher herunterladen. Dort liest und schreibt er jede Zeile deiner Datenbank, egal was deine Regeln sagen. Ist das schon passiert, rotiere ihn zuerst.

Dieses Versehen sieht man von außen. Unser kostenloser Scan liest das JavaScript deiner Live-App so, wie ein Fremder es täte, und nennt jeden Schlüssel, den er findet, samt der Frage, ob er dorthin gehört. Das dauert etwa 20 Sekunden und braucht kein Konto: scanne deine App.

Die Prüfung löschen und die ID des Nutzers aus der Anfrage nehmen. Eine Funktion, die userId aus dem Inhalt der Anfrage liest, glaubt jedem, der sie schickt. Schreibt diese Funktion außerdem mit dem geheimen Schlüssel, kann jeder, der deine Seite öffnet und nachliest, wie die Funktion aufgerufen wird, als jeder deiner Nutzer handeln, und nichts in deinen Logs sagt, dass sie es nicht waren. Bleibt die Prüfung drin, bekommt ein Fremder, der denselben Aufruf versucht, diesen Fehler zurück und sonst nichts.

Die anderen Meldungen derselben Prüfung

Die Prüfungen von Auth laufen in fester Reihenfolge, und jede hat ihren eigenen Satz, also sagt dir die Meldung, wie weit das Token gekommen ist.

MeldungStatus und CodeWas passiert ist
This endpoint requires a valid Bearer token401 no_authorizationEs kam überhaupt kein Header Authorization: Bearer an.
invalid JWT: unable to parse or verify signature, …403 bad_jwtDer Stempel stimmt nicht: ein beschädigtes Token, eins aus einem anderen Projekt oder ein alter Schlüssel.
invalid claim: missing sub claim403 bad_jwtDer Stempel stimmt, und die Namenszeile ist leer: ein API-Schlüssel.
invalid claim: sub claim must be a UUID400 bad_jwtEin Name steht da, aber nicht in der Form einer Nutzer-ID von Supabase.
User from sub claim in JWT does not exist403 user_not_foundDas Token nennt einen Nutzer, der inzwischen gelöscht wurde.
Session from session_id claim in JWT does not exist403 session_not_foundDas Token hat seine Sitzung überlebt, zum Beispiel nach einer Abmeldung auf einem anderen Gerät.

Jede Zeile stammt aus derselben Datei, internal/api/auth.go. Die Version der Zeile mit dem falschen Stempel, die deine Datenbank selbst meldet, PGRST301, hat einen eigenen Beitrag. Die Zeile zur Sitzung liest du am besten zusammen mit warum Supabase-Nutzer abgemeldet werden.

Damit die Prüfung nach der nächsten Änderung bleibt

Eine Prüfung, wer da aufruft, kann verschwinden, wenn du deinen Builder das nächste Mal bittest, einen Fehler wegzumachen, und die zwei schnellsten Wege, diesen hier zum Schweigen zu bringen, sind die beiden oben.

Reeve Monitor scannt deine Live-App jede Stunde neu:

  • alle neun Checks jede Stunde, für bis zu drei Apps
  • eine E-Mail an dem Tag, an dem ein erneuter Scan deine App schlechter benotet als der vorige; ein geheimer Schlüssel in deinem JavaScript deckelt die Note allein schon bei D
  • ob die App erreichbar ist, alle 60 Sekunden
  • ein monatlicher Bericht darüber, was gesehen wurde

Eine Funktion, die der Nutzer-ID glaubt, die man ihr schickt, kann für jeden, der sie aufruft, die Zeilen jedes deiner Nutzer ändern. Reeve Care hält eine Kopie deiner Supabase-Datenbank von davor.

  • eine verschlüsselte Kopie nach Zeitplan, auf Reeves eigenem Speicher in der EU, außerhalb deines Supabase-Kontos
  • jede Kopie wird geprüft, bevor sie zählt, durch Zählen der Zeilen in jeder Tabelle
  • eine Wiederherstellung per Klick, die sichert, was jetzt da ist, bevor sie es ersetzt
  • deine hochgeladenen Dateien ebenfalls, sobald du eine Storage-Zugangsberechtigung verbindest
  • alles, was Monitor tut

Was jetzt zu tun ist

Was zu tun ist

  • Öffne die fehlgeschlagene Anfrage im Tab Network. Trägt Authorization denselben Wert wie apikey, war beim Abschicken niemand angemeldet.
  • Ruf Funktionen, die eine Person brauchen, erst nach der Anmeldung auf, und lass die Funktion mit 401 antworten, wenn getUser() keinen Nutzer findet.
  • Übergib auf deinem eigenen Server das Zugriffstoken der Person an getUser(). Im Schlüssel deines Projekts steht kein Nutzer.
  • Lass den geheimen Schlüssel, wo er ist. Auch er nennt niemanden, und in der Seite liest und schreibt er jede Zeile.
  • Behalte die Prüfung, wer da aufruft. Eine Funktion, die die ID des Nutzers aus der Anfrage nimmt, glaubt jedem.

Zeigt deine App Edge Function returned a non-2xx status code, während diese Meldung im Log steht, reicht die Funktion die Absage weiter, und dieser Beitrag liest die Antwort von dort aus weiter. Die 10-Minuten-Sicherheitscheckliste deckt ab, was in einer frisch gestarteten App sonst gern offen bleibt.

FAQ

Was bedeutet "invalid claim: missing sub claim" in Supabase?

Es heißt, dass Supabase Auth gefragt wurde, welcher Nutzer aufruft, und ein Token ohne Nutzer darin bekam. Das Token selbst war echt und hat die Signaturprüfung bestanden. Es fehlte der Claim sub, das Feld, in dem die ID eines Nutzers steht. Genau so sehen die API-Schlüssel deines Projekts aus, und deshalb ist die übliche Ursache ein Schlüssel, der dort geschickt wurde, wo das Token eines angemeldeten Nutzers hingehört.

Ist das dasselbe wie "Auth session missing!"?

Nein. "Auth session missing!" kommt von supabase-js im Browser, das keine Sitzung findet und die Anfrage gar nicht erst abschickt. "invalid claim: missing sub claim" kommt von Supabase Auth zurück, das eine Anfrage bekommen und einen Schlüssel gefunden hat, wo das Token des Nutzers sein sollte. Das Erste heißt meist: eine Seite ohne Anmeldung. Das Zweite heißt meist: Eine Funktion oder ein Server hat den Schlüssel weitergereicht.

Verschwindet der Fehler, wenn ich Verify JWT abschalte?

Nein. Verify JWT ist eine Prüfung, die Supabase bei einer Edge Function ausführt, bevor dein Code startet, und diese Meldung kommt später, von Supabase Auth, wenn dein Code fragt, wer da aufruft. Schaltest du die Plattformprüfung ab, erreichen auch Anfragen ganz ohne Token deinen Code, und dieser Fehler bleibt genau, wo er war.

Löst eine anonyme Anmeldung diesen Fehler aus?

Nein. Eine anonyme Anmeldung legt einen echten Nutzer mit eigener ID an, also trägt sein Token einen sub-Claim und is_anonymous auf true. Der anon-Schlüssel ist etwas anderes mit ähnlichem Namen: ein Projektschlüssel ohne Nutzer darin, und er ist es, der diese Meldung erzeugt.

Warum steht auf manchen Seiten 401 und auf anderen 403?

Die Fehlerseite von Supabase zu dieser Meldung hat 401 im Titel. Der aktuelle Code von Supabase Auth antwortet mit 403 und dem Fehlercode bad_jwt. Wenn dein Code allein am Status entscheidet, was er tut, prüf auch den Fehlercode und die Meldung.

Geschrieben von

Vlad Tkachenko

Gründer, Reeve

Ich schaue mir den ganzen Tag Apps an, die mit Lovable, Bolt, v0, Cursor und Replit gebaut wurden, und die kurze Liste von Fehlern, die darin immer wieder auftauchen.

Mehr über den Autor

Weiterlesen

Alle Artikel

Sichere deine Supabase-Datenbank vor der nächsten Änderung

Reeve sichert sie verschlüsselt nach Zeitplan und stellt sie für dich wieder her, wenn etwas schiefgeht.

Supabase-Backups ansehen

Automatisierte externe Prüfung, kein vollständiges Audit. Das Fehlen von Funden ist keine Garantie für Sicherheit.