Aller au contenu

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.

Vlad Tkachenko12 min de lecture
Un badge au bout d'un cordon, avec un sceau authentique et une ligne de nom vide, devant deux badges qui portent un nom et un visage.

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.

Le sceau est vérifié d'abord, et les deux badges passent. La question qui échoue est la seconde : quel nom y figure ?

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.

Quand personne n'est connecté, la page envoie la même clé dans les deux en-têtes. Chaque vérification avant Auth la laisse passer, et seule la question d'Auth trouve la ligne du nom vide.

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 :

  • Authorization contient Bearer suivi de la même chaîne que apikey. La page n'avait pas de session à envoyer, alors elle a envoyé la clé deux fois. Vous êtes sur le chemin 1 ou 2.
  • Authorization contient une autre longue chaîne commençant par eyJ. 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épond This 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é.

MessageStatut et codeCe qui s'est passé
This endpoint requires a valid Bearer token401 no_authorizationAucun en-tête Authorization: Bearer n'est arrivé.
invalid JWT: unable to parse or verify signature, …403 bad_jwtLe tampon est faux : un jeton abîmé, un jeton d'un autre projet ou une ancienne clé.
invalid claim: missing sub claim403 bad_jwtLe tampon est bon et la ligne du nom est vide : une clé d'API.
invalid claim: sub claim must be a UUID400 bad_jwtUn nom est là, mais pas sous la forme d'un identifiant d'utilisateur Supabase.
User from sub claim in JWT does not exist403 user_not_foundLe jeton nomme un utilisateur supprimé depuis.
Session from session_id claim in JWT does not exist403 session_not_foundLe 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 Authorization porte la même valeur que apikey, 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.

Écrit par

Vlad Tkachenko

Fondateur de Reeve

Je passe mes journées à examiner des applications créées avec Lovable, Bolt, v0, Cursor et Replit, et la courte liste d'erreurs qui y reviennent sans cesse.

En savoir plus sur l'auteur

À lire ensuite

Tous les articles

Sauvegardez votre base Supabase avant la prochaine modification

Reeve en garde une copie chiffrée à intervalles réguliers et la restaure pour vous en cas de problème.

Voir les sauvegardes Supabase

Vérification externe automatisée, pas un audit complet. L'absence de problème n'est pas une garantie de sécurité.