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.

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.
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.
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:
AuthorizationenthältBearerund danach dieselbe Zeichenkette wieapikey. Die Seite hatte keine Sitzung zu schicken, also hat sie den Schlüssel zweimal geschickt. Du bist auf Weg 1 oder 2.Authorizationenthält eine andere lange Zeichenkette, die miteyJbeginnt. 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 mitThis 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.
| Meldung | Status und Code | Was passiert ist |
|---|---|---|
This endpoint requires a valid Bearer token | 401 no_authorization | Es kam überhaupt kein Header Authorization: Bearer an. |
invalid JWT: unable to parse or verify signature, … | 403 bad_jwt | Der Stempel stimmt nicht: ein beschädigtes Token, eins aus einem anderen Projekt oder ein alter Schlüssel. |
invalid claim: missing sub claim | 403 bad_jwt | Der Stempel stimmt, und die Namenszeile ist leer: ein API-Schlüssel. |
invalid claim: sub claim must be a UUID | 400 bad_jwt | Ein Name steht da, aber nicht in der Form einer Nutzer-ID von Supabase. |
User from sub claim in JWT does not exist | 403 user_not_found | Das Token nennt einen Nutzer, der inzwischen gelöscht wurde. |
Session from session_id claim in JWT does not exist | 403 session_not_found | Das 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
Authorizationdenselben Wert wieapikey, 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.