Bases de la sécurité
Supabase "invalid claim: missing sub claim" : personne de connecté
"invalid claim: missing sub claim" : Supabase Auth a reçu un vrai jeton qui ne nomme personne, la clé du projet à la place d'une session.

En bref
- "invalid claim: missing sub claim", c'est Supabase Auth qui répond 403 avec le code bad_jwt. On lui a demandé qui appelle, le jeton reçu était authentique, et il ne nommait personne.
- La page de dépannage de Supabase nomme la cause courante : une clé d'API envoyée là où devrait se trouver le jeton d'accès d'un utilisateur connecté. Une clé dit quelle app appelle et ne contient aucune personne.
- N'appelez la fonction qu'une fois quelqu'un connecté, et faites-la répondre 401 quand personne ne l'est. Mettre la clé secrète à la place ne change rien, car elle ne nomme personne non plus.
Votre app a une Supabase Edge Function qui fait quelque chose pour la personne qui l'utilise, comme enregistrer ses réglages ou appeler OpenAI pour son compte. Pendant vos tests, elle marchait. Puis le journal de la fonction se remplit de cette ligne, et l'app affiche une erreur, ou rien du tout :
AuthApiError: invalid claim: missing sub claim
Cherchez "invalid claim: missing sub claim" et l'on vous conseillera de
régénérer vos clés, de couper Verify JWT ou de mettre la clé service_role.
Chacun de ces conseils traite le jeton comme cassé. Le jeton allait bien.
Supabase a vérifié sa signature et l'a accepté. Ce qui lui manquait, c'était un
nom, n'importe lequel.
Que signifie "invalid claim: missing sub claim" ?
On a demandé à Supabase Auth quel utilisateur appelle ; il a trouvé le jeton authentique, et aucun utilisateur inscrit dedans.
Les jetons que lit Supabase Auth sont des JWT : trois blocs de texte séparés par
des points, dont celui du milieu est une donnée lisible. Chaque élément de cette
donnée s'appelle un claim. sub, pour subject, est le claim qui contient
l'identifiant d'un utilisateur, et la
référence JWT de Supabase le
décrit comme l'ID de l'utilisateur, un UUID.
Le message est écrit dans le code source de Supabase Auth lui-même, dans une
fonction appelée maybeLoadUserOrSession du fichier internal/api/auth.go.
Quand cette fonction s'exécute, la signature du jeton a déjà été vérifiée, et
elle a passé. Elle cherche alors sub, et quand le claim est vide, elle
s'arrête avec un HTTP 403, le code d'erreur bad_jwt et cette phrase. La
réponse que reçoit votre code ressemble à ceci :
{"code":403,"error_code":"bad_jwt","msg":"invalid claim: missing sub claim"}
Imaginez un accueil à qui l'on pose une seule question : quel visiteur est-ce ? Vous tendez un badge. L'accueil vérifie d'abord le tampon, et le tampon est authentique. Puis il lit la ligne du nom, et la ligne du nom est vide. L'accueil n'a rien pour répondre, alors il refuse, et le refus désigne la ligne vide.
Tout appel qui a besoin d'une personne connectée peut recevoir cette réponse.
getUser() et updateUser() sont les deux où vous la verrez le plus
probablement, et l'ajout d'un second facteur ou l'association d'une autre
méthode de connexion passent par la même vérification.
Une clé nomme votre app. Un jeton de session nomme une personne.
La clé d'API de votre projet est le badge à la ligne de nom vide, et elle a été faite ainsi exprès.
La documentation des clés d'API le dit en une ligne : selon Supabase, une clé d'API ne distingue pas les utilisateurs, elle distingue les applications. La clé dit à Supabase quelle app appelle. Qui utilise cette app est une autre question, à laquelle répond un jeton que Supabase Auth délivre à chaque personne quand elle se connecte.
La référence JWT de Supabase imprime les deux. La clé anon d'un projet plus
ancien se décode ainsi, et il n'y a de sub nulle part :
{
"iss": "supabase",
"ref": "abcdefghijklmnopqrst",
"role": "anon",
"iat": 1640991600,
"exp": 1640995200
}
Le jeton d'accès d'une personne connectée porte son identifiant, le rôle
authenticated et la session à laquelle il appartient (réduit ici aux lignes
qui comptent) :
{
"sub": "123e4567-e89b-12d3-a456-426614174000",
"role": "authenticated",
"session_id": "123e4567-e89b-12d3-a456-426614174000",
"email": "user@example.com"
}
La clé service_role reprend la première forme, avec "role": "service_role",
et toujours sans sub. Les clés plus récentes sb_publishable_ et sb_secret_
ne sont pas du tout des JWT, mais la
page de dépannage de Supabase pour cette erreur
dit qu'Auth les reconnaît et qu'elles ne portent pas de claim sub non plus.
Toutes les clés de votre projet, anciennes ou nouvelles, publiques ou secrètes,
produisent donc ce message quand on les remet à Auth comme si c'était une
personne. La même page nomme l'erreur sans détour : utiliser par mégarde une clé
d'API Supabase au lieu du jeton d'accès d'Auth.
Si les clés et leurs préfixes sont nouveaux pour vous, quelles clés d'API peuvent aller dans votre frontend explique comment distinguer la publique de la secrète.
Où la clé est envoyée à la place d'une session
Trois chemins mènent ici, et le premier est celui que prend une Edge Function.
1. Une Edge Function appelée depuis une page où personne n'est connecté.
Quand supabase-js a une session, il met le jeton de la personne dans l'en-tête
Authorization. Quand il n'en a pas, il n'a rien d'autre à y mettre, donc avec
une clé ancienne il y envoie la clé de votre projet à la place. Son fichier
fetch.ts se lit exactement ainsi : le jeton de session s'il y en a un, sinon la
clé.
La vérification Verify JWT de la plateforme laisse entrer cette requête. Une clé ancienne est un JWT signé par votre projet, et Supabase documente que la vérification accepte aussi les clés plus récentes : c'est la faille que déplacer un secret dans une Edge Function explique en entier. Votre fonction transmet ensuite l'en-tête à Auth, et c'est ce que font ces lignes :
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 lit la clé, trouve la ligne de nom vide et répond 403. Si la fonction
renvoie cet échec, votre app affiche Edge Function returned a non-2xx status code, et ce message attend dans le journal de la fonction.
2. Un appel fait avant la fin de la connexion. La page de dépannage de
Supabase nomme aussi ce cas : un utilisateur qui avait commencé à s'inscrire ou
à se connecter sans avoir terminé quand getUser a été appelé. Une forme que
cela prend : une inscription qui attend encore son e-mail de confirmation. Il
n'y a pas encore de session, donc ce qui est arrivé chez Auth n'était pas le
jeton d'une personne.
3. Votre propre serveur remet à Auth la mauvaise valeur. Une route qui
appelle getUser(token) avec la clé dans token, ou une automatisation dans
n8n, Make ou Postman qui appelle /auth/v1/user avec la clé dans son en-tête
Authorization. La solution que donne la page de Supabase pour ce cas :
s'assurer que le jeton passé à getUser() est bien le jeton d'accès de la
personne.
Comment voir ce que votre fonction a reçu
Comparez deux en-têtes de requête dans votre navigateur.
Ouvrez votre app en ligne, appuyez sur F12, choisissez l'onglet Network et
refaites ce qui a échoué. Cliquez sur la requête dont l'adresse contient
/functions/v1/ et le nom de votre fonction, et lisez ses en-têtes de requête :
AuthorizationcontientBearersuivi de la même chaîne queapikey. La page n'avait pas de session à envoyer, alors elle a envoyé la clé deux fois. Vous êtes sur le chemin 1 ou 2.Authorizationcontient une autre longue chaîne commençant pareyJ. Le jeton d'une personne est bien parti. La fonction ou votre serveur l'a perdu en route, vous êtes donc sur le chemin 3.- Il n'y a pas d'en-tête
Authorization. Alors ce n'est pas ce message que vous verrez. Avec Verify JWT activé, la plateforme refuse la requête par un 401 avant que votre code tourne, ce que l'article sur l'erreur non-2xx lit. Désactivé, Auth répondThis endpoint requires a valid Bearer token, la première ligne du tableau plus bas.
Si le message apparaît plutôt dans la console de votre navigateur et dit
Auth session missing!, aucune requête n'a atteint Auth : supabase-js n'a
trouvé aucune session et s'est arrêtée avant d'en envoyer une, ce que
son propre article traite.
Lisez les valeurs là où elles sont. Ne collez ni l'une ni l'autre dans un site qui propose de la décoder : votre clé est publique de toute façon, mais le jeton d'accès d'une personne permet à quiconque le détient d'agir en son nom jusqu'à son expiration.
La solution : ne demander que quand quelqu'un est connecté
Décidez ce que la fonction doit faire pour un visiteur qui ne s'est pas connecté, et écrivez cette décision dans la fonction.
Si seules les personnes connectées doivent l'utiliser, deux changements. La
page n'appelle la fonction qu'une fois quelqu'un connecté. Et la fonction répond
un 401 net quand getUser() ne trouve aucun utilisateur, pour qu'un appel
égaré depuis une page sans connexion reçoive un refus clair plutôt qu'un plantage :
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' },
})
}
Gardez aussi les en-têtes CORS sur cette réponse, sinon le navigateur la cache à
votre app et vous tombez sur
"Failed to send a request to the Edge Function"
à la place. Supabase fournit aussi un wrapper, withSupabase({ auth: 'user' }),
qui fait la même vérification avant que votre handler tourne ;
l'article sur l'Edge Function montre
la mise en place.
Si les visiteurs non connectés doivent aussi l'utiliser, ne demandez à Auth que sur le chemin qui a besoin d'une personne. Traitez un refus à cet endroit comme "c'est un visiteur" et continuez avec ce qu'un visiteur a le droit de faire, plutôt que de laisser l'erreur arrêter la requête.
Sur votre propre serveur, envoyez le jeton d'accès de la personne depuis le
navigateur, comme le décrit la page de Supabase : mettez-le dans un en-tête
Authorization: Bearer de votre requête, lisez-le sur le serveur et appelez
supabase.auth.getUser(token) avec cette valeur. La clé de votre projet n'entre
jamais dans cet appel.
Deux solutions qui aggravent les choses
Les deux sont suggérées pour cette erreur, et les deux vous laissent dans une situation pire que l'erreur elle-même.
Mettre la clé secrète à la place. Cela ne peut pas aider, car service_role
et sb_secret_ ne portent pas de sub non plus, et Auth répond donc de la même
façon. Le mal tient à l'endroit où la clé finit. Demandez à un builder
d'"utiliser la clé de service" pour passer une erreur, et il pourrait bien la
mettre dans la page que vos visiteurs téléchargent, où elle lit et écrit chaque
ligne de votre base quelles que soient vos règles. Si c'est déjà arrivé,
faites-la tourner d'abord.
Ce faux pas se voit de l'extérieur. Notre scan gratuit lit le JavaScript de votre app en ligne comme le ferait un inconnu et nomme chaque clé qu'il trouve, en précisant si elle a sa place là. Il prend environ 20 secondes et ne demande pas de compte : scannez votre app.
Supprimer la vérification et prendre l'identifiant de l'utilisateur dans la
requête. Une fonction qui lit userId dans le corps de la requête croit
quiconque l'envoie. Si cette fonction écrit en plus avec la clé secrète,
n'importe qui ouvrant votre page et lisant comment la fonction est appelée peut
agir comme n'importe lequel de vos utilisateurs, et rien dans vos journaux ne
dit que ce n'étaient pas eux. Avec la vérification en place, un inconnu qui
tente le même appel reçoit cette erreur, et rien d'autre.
Les autres messages de la même vérification
Les vérifications d'Auth s'exécutent dans un ordre fixe et chacune a sa propre phrase, donc le message vous dit jusqu'où le jeton est allé.
| Message | Statut et code | Ce qui s'est passé |
|---|---|---|
This endpoint requires a valid Bearer token | 401 no_authorization | Aucun en-tête Authorization: Bearer n'est arrivé. |
invalid JWT: unable to parse or verify signature, … | 403 bad_jwt | Le tampon est faux : un jeton abîmé, un jeton d'un autre projet ou une ancienne clé. |
invalid claim: missing sub claim | 403 bad_jwt | Le tampon est bon et la ligne du nom est vide : une clé d'API. |
invalid claim: sub claim must be a UUID | 400 bad_jwt | Un nom est là, mais pas sous la forme d'un identifiant d'utilisateur Supabase. |
User from sub claim in JWT does not exist | 403 user_not_found | Le jeton nomme un utilisateur supprimé depuis. |
Session from session_id claim in JWT does not exist | 403 session_not_found | Le jeton a survécu à sa session, par exemple après une déconnexion sur un autre appareil. |
Chaque ligne vient du même fichier, internal/api/auth.go. La version que votre
base de données renvoie elle-même pour la ligne du mauvais tampon, PGRST301, a
son propre article. La ligne de la session se lit
avec pourquoi les utilisateurs Supabase sont déconnectés.
Garder la vérification après le prochain changement
Une vérification de qui appelle peut disparaître la prochaine fois que vous demandez à votre builder de faire disparaître une erreur, et les deux moyens les plus rapides de faire taire celle-ci sont les deux ci-dessus.
Reeve Monitor rescanne votre app en ligne toutes les heures :
- les neuf vérifications toutes les heures, sur trois apps au plus
- un e-mail le jour où un nouveau scan note votre app moins bien que le précédent ; une clé secrète dans votre JavaScript plafonne à elle seule la note à D
- si l'app répond, toutes les 60 secondes
- un rapport mensuel de ce qui a été vu
Une fonction qui croit l'identifiant d'utilisateur qu'on lui envoie peut modifier les lignes de n'importe lequel de vos utilisateurs pour quiconque l'appelle. Reeve Care garde une copie de votre base Supabase d'avant.
- une copie chiffrée selon un calendrier, conservée sur le stockage de Reeve dans l'UE, en dehors de votre compte Supabase
- chaque copie vérifiée avant de compter, en comptant les lignes de chaque table
- une restauration en un clic qui sauvegarde ce qui est là avant de le remplacer
- vos fichiers téléversés aussi, dès que vous connectez un accès Storage
- tout ce que fait Monitor
Que faire maintenant
Que faire
- Ouvrez la requête qui échoue dans l'onglet Network. Si
Authorizationporte la même valeur queapikey, personne n'était connecté quand elle est partie. - N'appelez les fonctions qui ont besoin d'une personne qu'après la connexion, et faites répondre la fonction 401 quand
getUser()ne trouve aucun utilisateur. - Sur votre propre serveur, passez à
getUser()le jeton d'accès de la personne. La clé de votre projet ne contient aucun utilisateur. - Laissez la clé secrète où elle est. Elle ne nomme personne non plus, et dans la page elle lit et écrit chaque ligne.
- Gardez la vérification de qui appelle. Une fonction qui prend l'identifiant de l'utilisateur dans la requête croit n'importe qui.
Si votre app affiche Edge Function returned a non-2xx status code pendant que
ce message est dans le journal, la fonction transmet le refus, et
cet article lit la réponse
à partir de là. La checklist de sécurité en 10 minutes couvre ce
qu'une app qui vient d'être lancée laisse souvent ouvert.
FAQ
Que signifie "invalid claim: missing sub claim" dans Supabase ?
Cela signifie qu'on a demandé à Supabase Auth quel utilisateur appelle et qu'on lui a remis un jeton sans utilisateur dedans. Le jeton lui-même était authentique et a passé la vérification de signature. Il lui manquait le claim sub, le champ qui contient l'identifiant d'un utilisateur. C'est exactement à cela que ressemblent les clés d'API de votre projet, d'où la cause habituelle : une clé envoyée là où le jeton d'un utilisateur connecté devrait se trouver.
Est-ce la même chose que "Auth session missing!" ?
Non. "Auth session missing!" vient de supabase-js dans le navigateur, qui ne trouve aucune session et n'envoie même pas la requête. "invalid claim: missing sub claim" revient de Supabase Auth, qui a reçu une requête et trouvé une clé là où devait être le jeton de l'utilisateur. Le premier signale le plus souvent une page sans connexion ; le second, une fonction ou un serveur qui a transmis la clé.
Désactiver Verify JWT règle-t-il le problème ?
Non. Verify JWT est une vérification que Supabase fait sur une Edge Function avant que votre code démarre, et ce message arrive plus tard, de Supabase Auth, quand votre code demande qui appelle. Couper la vérification de la plateforme laisse aussi passer jusqu'à votre code les requêtes sans aucun jeton, et l'erreur reste exactement où elle était.
Une connexion anonyme provoque-t-elle cette erreur ?
Non. Une connexion anonyme crée un vrai utilisateur avec son propre identifiant, donc son jeton porte un claim sub et is_anonymous à true. La clé anon est autre chose malgré son nom : une clé de projet sans utilisateur dedans, et c'est elle qui produit ce message.
Pourquoi certaines pages disent 401 et d'autres 403 ?
La page de dépannage de Supabase pour ce message porte 401 dans son titre. Le code actuel de Supabase Auth répond 403, avec le code d'erreur bad_jwt. Si votre code décide quoi faire d'après le seul statut, vérifiez aussi le code d'erreur et le message.