Bases de la sécurité
Supabase "Email link is invalid or has expired" sur un lien neuf
Sur un lien envoyé il y a une minute, "Email link is invalid or has expired" de Supabase signifie souvent qu'un filtre de messagerie l'a utilisé avant.

En bref
- Supabase affiche "Email link is invalid or has expired" quand le jeton d'un lien de connexion ou de confirmation a déjà servi, a été remplacé par un e-mail plus récent, ou a plus d'une heure. Le code d'erreur est otp_expired.
- Sur un lien que la personne vient de recevoir, la cause habituelle est un filtre de messagerie qui a ouvert le lien pour le vérifier et l'a consommé ce faisant. La page de dépannage de Supabase elle-même en fait la raison la plus courante.
- Allonger la validité ne change rien. Changez l'e-mail pour qu'ouvrir l'adresse ne déclenche rien : envoyez un code à taper, ou un lien vers une page où un bouton connecte la personne.
Un nouvel utilisateur s'inscrit, ouvre l'e-mail de confirmation une minute plus tard et appuie sur le bouton. Votre app s'ouvre sur sa page d'accueil, toujours déconnectée, et la barre d'adresse se termine ainsi :
#error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired
Vous vous inscrivez avec votre propre adresse pour vérifier. Le lien marche du premier coup, et à chaque essai ensuite.
Supabase dit "Email link is invalid or has expired" quand le jeton d'un lien d'e-mail a déjà servi, a été remplacé ou a dépassé son heure. Sur un lien arrivé il y a une minute, la raison habituelle est que quelque chose l'a utilisé avant votre utilisateur : un filtre de messagerie qui ouvre les liens pour les vérifier. Votre app Lovable, Bolt, v0, Cursor, Claude Code ou Replit fonctionne comme elle a été construite, et l'e-mail qu'elle envoie peut être modifié pour qu'un filtre n'ait plus rien à consommer.
Voici ce que le conseil habituel fait de travers : "demandez-lui de réessayer" et "rendez le lien valable plus longtemps" échouent tous les deux face à un filtre. Un deuxième lien passe par le même filtre et finit de la même façon, quelle qu'ait été sa durée prévue.
Que signifie "Email link is invalid or has expired" dans Supabase ?
Cela signifie que Supabase Auth, la partie de Supabase qui connecte les gens, n'a trouvé derrière le lien aucun jeton qu'il était prêt à accepter.
Le message est écrit dans le code même du serveur Auth. Dans
la fonction qui vérifie un lien d'e-mail,
il est renvoyé à deux endroits : quand aucun jeton en attente ne correspond à
celui du lien, et quand le jeton qui correspond a été envoyé il y a plus
longtemps que votre projet ne le permet. Les deux réponses sont un HTTP 403 avec
le code otp_expired, donc le message seul ne vous dit pas lequel des deux
s'est produit.
Votre utilisateur ne voit jamais le 403. Le lien de l'e-mail pointe vers
l'adresse propre de votre projet Supabase, qui vérifie le jeton puis renvoie le
navigateur vers votre app. Quand la vérification échoue, Supabase place trois
choses dans la partie de l'adresse de votre app qui suit le # :
error=access_denied, error_code=otp_expired et le message lui-même. Si votre
app a été configurée avec le flux PKCE, elles figurent aussi dans la partie qui
suit le ?. Tant qu'aucune page de votre app ne les lit, la personne voit ce
que votre app montre à un visiteur non connecté, sans un mot sur la raison. Une
page de réinitialisation du mot de passe qui affiche quand même son formulaire
échoue à l'enregistrement avec « Auth session missing! », parce que le lien n'a
connecté personne, et ce message a son propre article.
Voyez le lien comme un billet qui laisse entrer une personne, une seule fois. Qui le présente en premier passe, et le billet est poinçonné. "Email link is invalid or has expired", c'est la personne à la porte qui tend un billet déjà troué, ou un billet dont l'heure est passée. Si vos utilisateurs entrent puis se font déconnecter plus tard, c'est une autre erreur, avec sa propre page.
Pourquoi un lien envoyé il y a une minute a déjà servi
Parce que quelque chose l'a ouvert avant votre utilisateur, et qu'ouvrir un lien d'e-mail Supabase revient à l'utiliser.
Le guide des modèles d'e-mail de Supabase le range parmi ses limitations : certains fournisseurs de messagerie ont une détection de spam ou d'autres fonctions de sécurité qui ouvrent à l'avance les liens des e-mails entrants. L'exemple qu'il donne est Safe Links dans Microsoft Defender for Office 365. La page de dépannage de Supabase pour cette erreur fait de cette ouverture anticipée la raison la plus courante pour laquelle un jeton semble expiré ou invalide avant même que l'utilisateur puisse s'en servir.
Le lien par défaut n'a aucune étape entre l'ouverture et l'utilisation. Son adresse est le point de vérification de votre projet, qui contrôle le jeton et le consomme à la première visite, quel que soit le visiteur. Le filtre présente donc le billet à l'arrivée du message, et il passe. Votre utilisateur clique une minute plus tard et se fait refouler à la porte.
Deux choses en découlent.
- Vous ne le verrez pas vous-même. Cela arrive aux personnes dont le courrier passe par un filtre de ce genre, et votre propre boîte n'en fait peut-être jamais partie. Vos inscriptions de test continuent de marcher pendant que vos utilisateurs vous écrivent.
- Le compte est souvent déjà confirmé. Pour une confirmation d'inscription, la visite du filtre compte : Supabase confirme l'adresse de quiconque ouvre le lien. Si un filtre l'a ouvert, le compte est confirmé au moment où votre utilisateur voit l'erreur, et il peut se connecter avec le mot de passe qu'il a choisi.
Les trois autres causes
Chacune produit le même message, et chacune vous demande autre chose.
Le lien a déjà servi. Votre utilisateur l'a utilisé une fois, peut-être sur son téléphone, et ouvre maintenant le même e-mail sur son ordinateur portable. Quand un lien fonctionne, Supabase efface tous les liens encore en attente pour ce compte. Son code des comptes fait le même nettoyage après une confirmation et après un lien de connexion, donc un deuxième clic ne trouve plus rien. Le billet a été poinçonné à la première visite.
Un e-mail plus récent l'a remplacé. Appuyer sur "renvoyer" crée un nouveau lien, et Supabase supprime l'ancien du même type avant de l'enregistrer. Cela se passe dans le code qui crée le jeton. Le premier e-mail reste dans la boîte, l'air de rien, et son lien échoue. Seul le plus récent fonctionne. Supabase ne laisse aussi une adresse demander ce type d'e-mail qu'une fois toutes les 60 secondes, ce que la limite d'envoi d'e-mails traite en détail.
Il a vraiment expiré. Le guide de connexion sans mot de passe de Supabase indique que les magic links expirent par défaut au bout d'1 heure. La même page précise que le réglage Email OTP Expiration régit aussi la validité des magic links et des autres liens d'e-mail, dont ceux de confirmation, de récupération de mot de passe, de changement d'e-mail et d'invitation. Quelqu'un qui s'inscrit le soir et ouvre l'e-mail le lendemain matin tombe sur ce message, quoi que fasse son service de messagerie.
Que se passe-t-il quand le lien est ouvert sur un autre appareil ?
Si votre app utilise le flux PKCE, l'autre appareil échoue d'abord avec un autre message, et celui-ci suit.
PKCE coupe le billet en deux. Le guide PKCE de Supabase explique que le code verifier est créé et stocké localement au démarrage du flux Auth, et que l'échange du code doit donc être lancé dans le même navigateur et sur le même appareil que ceux où le flux a commencé. L'e-mail porte une moitié. Le navigateur où votre utilisateur a demandé le lien garde le talon.
Ouvert sur un téléphone, le lien passe tout de même par la vérification de Supabase, qui le consomme. Puis votre app cherche le talon sur le téléphone et n'en trouve aucun. Le client JavaScript de Supabase le signale avec ses propres mots :
PKCE code verifier not found in storage. This can happen if the auth flow was initiated in a different browser or device, or if the storage was cleared.
La personne revient alors sur son ordinateur et ouvre là le même e-mail, et
Supabase répond "Email link is invalid or has expired", parce que le téléphone a
déjà fait poinçonner le billet. Si l'adresse sur laquelle s'ouvre votre app
après un lien qui marche contient ?code=, votre app utilise PKCE et ce cas
vous concerne.
Comment savoir de quelle cause il s'agit ?
Interrogez votre propre base de données. Chaque compte de votre projet est une
ligne dans une table appelée auth.users, et cette ligne enregistre quand
chaque type d'e-mail a été envoyé et quand l'adresse a été confirmée.
Ouvrez le SQL Editor dans votre tableau de bord Supabase et lancez ceci avec l'adresse que la personne vous a donnée :
select email,
confirmation_sent_at,
email_confirmed_at,
recovery_sent_at
from auth.users
where lower(email) = lower('person@example.com');
La requête lit une ligne et ne modifie rien. confirmation_sent_at est le
moment où la dernière confirmation d'inscription est partie. recovery_sent_at
est le moment où le dernier magic link ou la dernière réinitialisation de mot de
passe est parti vers un compte qui existait déjà. Ce sont les moments à partir
desquels Supabase mesure l'heure, dans la même fonction qui renvoie l'erreur.
Comparez-les avec l'heure de l'e-mail sur lequel la personne a cliqué.
| Ce que vous voyez | Ce que cela veut dire |
|---|---|
Un lien de confirmation a échoué au premier clic, et email_confirmed_at est rempli | Quelque chose a ouvert le lien avant la personne. Le compte est confirmé, elle peut donc se connecter avec son mot de passe |
Un lien de confirmation a échoué, email_confirmed_at est vide, et elle a cliqué plus d'une heure après l'envoi | Il a expiré. Envoyez une nouvelle confirmation |
confirmation_sent_at ou recovery_sent_at est postérieur à l'e-mail sur lequel elle a cliqué | Un e-mail plus récent l'a remplacé. Seul le lien du plus récent fonctionne |
| Un magic link du dernier e-mail a échoué au premier clic, dans l'heure | Quelque chose l'a ouvert d'abord : un filtre de messagerie, ou la personne sur un autre appareil |
Si la ligne montre l'adresse confirmée et que la personne n'arrive toujours pas à entrer avec un mot de passe, Invalid login credentials est le message suivant à lire.
Ce qu'il ne faut pas changer pendant que vous corrigez
La validité, l'interrupteur Confirm email, et qui a le droit de confirmer un utilisateur. Chacun des raccourcis évidents fait cesser le message et ouvre quelque chose.
Laissez la validité à une heure. Un filtre ouvre le lien à l'arrivée du message, donc un lien valable une semaine sert quand même d'abord au filtre. Le guide de connexion sans mot de passe de Supabase déconseille fortement plus de 86 400 secondes, soit un jour. Un lien de connexion connecte quiconque l'ouvre. Plus il fonctionne longtemps, plus longtemps un e-mail transféré, une boîte partagée ou un vieux message dans les archives de quelqu'un est une porte d'entrée vers ce compte.
Laissez Confirm email activé. Le désactiver fait que les inscriptions n'envoient plus aucun lien, et cette erreur cesse. Cela veut aussi dire que n'importe qui peut créer un compte avec une adresse qu'il n'a jamais contrôlée.
Ne confirmez pas les utilisateurs depuis le navigateur. Demandez à un
assistant de "simplement les marquer comme confirmés" et il peut se tourner vers
les fonctions d'administration de Supabase, qui peuvent définir email_confirm
sur un compte. La
référence d'administration
de Supabase dit que toute méthode sous supabase.auth.admin exige une clé
secret et que cette clé ne doit jamais être exposée dans le navigateur. Une
clé secrète dans votre frontend lit et écrit chaque table de votre base de
données, quoi que disent vos règles, pour quiconque ouvre le code de votre site.
C'est la clé que
cet article sur les clés d'API
vous dit de renouveler. Si vous n'êtes pas sûr qu'aucune ne s'y est glissée,
notre scan gratuit lit le code que votre app en ligne envoie à chaque visiteur
et signale les clés secrètes qu'il y trouve. Il prend environ 20 secondes et ne
demande pas de compte : scannez votre app.
Vérifiez toute adresse que votre page de confirmation transmet. La deuxième solution proposée par Supabase est une page à vous avec un bouton, atteinte par un lien de cette forme :
<a href="{{ .SiteURL }}/confirm-signup?confirmation_url={{ .ConfirmationURL }}">
Le bouton mène alors là où le dit confirmation_url. Construite sans contrôle,
cette page envoie quiconque suit un lien trafiqué vers n'importe quelle adresse,
sous le nom de votre app. Vérifiez que l'adresse commence par celle de votre
propre projet, https://<project-ref>.supabase.co/auth/v1/verify, ou utilisez la
version avec le jeton de la section suivante, qui ne fait transiter aucune
adresse par la page.
Comment envoyer un lien qu'un filtre de messagerie ne peut pas consommer
Faites en sorte qu'ouvrir l'adresse ne déclenche rien, pour que seule une personne qui tape un code ou appuie sur un bouton se connecte. Supabase documente les deux, et vous les modifiez dans les modèles d'e-mail, sous Authentication dans votre tableau de bord. Confirm signup et Magic Link sont les deux à modifier.
Envoyez un code. Mettez {{ .Token }} dans le modèle. Le guide de Supabase
le décrit comme un mot de passe à usage unique de 6 chiffres qui peut remplacer
le lien. Votre page demande l'adresse e-mail et le code, puis appelle :
const { error } = await supabase.auth.verifyOtp({ email, token, type: 'email' })
Un filtre peut ouvrir un e-mail. Il ne peut pas taper un code dans votre page.
Faites un lien vers une page avec un bouton. Faites pointer le lien vers une page à vous et transmettez le hash du jeton, pour qu'aucune adresse n'ait à transiter :
<a href="{{ .SiteURL }}/confirm?token_hash={{ .TokenHash }}&type=email">Confirm your email</a>
{{ .SiteURL }} est la Site URL de la page URL Configuration de votre projet.
Si elle indique encore http://localhost:3000,
corrigez cela d'abord.
La page affiche un bouton, et seul le bouton fait le travail :
const params = new URLSearchParams(window.location.search)
async function onConfirmClick() {
const { error } = await supabase.auth.verifyOtp({
token_hash: params.get('token_hash'),
type: 'email',
})
if (error?.code === 'otp_expired') showSendNewLink()
}
Le type email couvre les deux modèles : la vérification de Supabase cherche le
hash parmi les confirmations d'inscription comme parmi les liens de connexion.
La page ne doit pas appeler verifyOtp au chargement. Le
guide des mots de passe de
Supabase construit son lien de confirmation sous la forme
{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}
et le vérifie au moment où cette adresse est ouverte. C'est de nouveau le lien
par défaut à une nouvelle adresse, et un filtre l'ouvre tout aussi facilement.
Quoi que vous changiez, certaines personnes tomberont encore sur un vieux lien, alors donnez-leur une suite. La page où arrivent vos liens peut lire l'erreur et proposer un nouvel e-mail :
const hash = new URLSearchParams(window.location.hash.slice(1))
if (hash.get('error_code') === 'otp_expired') showSendNewLink()
Pour une inscription, ajoutez une ligne disant que la personne est peut-être
déjà confirmée et peut essayer de se connecter. Si un builder écrit vos pages de
connexion, demandez-lui la version avec code ou avec bouton des deux modèles,
une page d'arrivée qui lit otp_expired et propose un nouveau lien, et aucune
clé d'administration nulle part dans l'app.
Être prévenu avant que vos utilisateurs vous écrivent
Cette erreur atteint vos utilisateurs et jamais vous, et d'autres pannes les atteignent eux aussi en premier : l'app qui tombe la nuit, ou un prompt ultérieur qui glisse une clé dans le code de votre app.
Reeve Monitor continue de vérifier votre app en ligne depuis l'extérieur :
- si elle répond, toutes les 60 secondes, pour distinguer "le lien ne marche pas" de "le site est en panne"
- les neuf vérifications toutes les heures, sur jusqu'à trois apps, y compris les clés secrètes dans le code que votre app envoie aux visiteurs
- un message quand un résultat change, pour qu'une clé partie avec un déploiement ultérieur vous parvienne sans que vous alliez voir
- un rapport mensuel, en langage clair, de ce qu'il a vu
Reeve Care garde une copie de votre base de données Supabase, comptes compris :
- une copie chiffrée selon un calendrier, sur le stockage propre de Reeve dans l'UE, en dehors de votre compte Supabase
- le schéma auth où vivent les comptes de vos utilisateurs, pour qu'ils reviennent avec tout le reste
- chaque copie vérifiée avant de compter, en comptant les lignes de chaque table
- vos fichiers envoyés aussi, dès que vous connectez un accès Storage
- une restauration en un clic, avec une copie de sécurité de l'état actuel prise d'abord
- tout ce que fait Monitor
Que faire maintenant
Que faire
- Lisez la ligne de la personne dans
auth.users. Unemail_confirmed_atrempli sur une confirmation qui a échoué veut dire qu'un filtre a ouvert le lien et qu'elle peut se connecter. - Dites aux gens d'utiliser l'e-mail le plus récent et, si votre app utilise PKCE, de l'ouvrir sur l'appareil où ils l'ont demandé.
- Passez les modèles Confirm signup et Magic Link à un code, ou à une page où un bouton appelle
verifyOtp. - Faites lire
error_code=otp_expiredà la page où arrivent vos liens, et proposez-y un nouveau lien. - Laissez la validité à une heure, laissez Confirm email activé et gardez la clé secrète hors du navigateur.
Si les e-mails n'arrivent pas du tout, la limite d'envoi d'e-mails est la première chose à écarter. La checklist de sécurité en 10 minutes couvre ce qui reste souvent ouvert dans une app qui vient d'être lancée.
FAQ
Pourquoi un magic link Supabase dit-il qu'il a expiré juste après l'envoi ?
Le plus souvent parce que quelque chose l'a ouvert avant la personne. Certains services de messagerie ouvrent les liens d'un message entrant pour les vérifier, et Supabase cite Safe Links dans Microsoft Defender for Office 365 en exemple. Ouvrir un lien d'e-mail Supabase, c'est l'utiliser, donc quand la personne clique une minute plus tard le jeton a disparu et Supabase répond "Email link is invalid or has expired".
Le compte est-il confirmé alors que le lien disait qu'il avait expiré ?
Si un filtre de messagerie a ouvert le premier un lien de confirmation d'inscription, oui. Supabase confirme l'adresse pour quiconque ouvre le lien, donc la visite du filtre l'a confirmée. Regardez email_confirmed_at dans auth.users : s'il est rempli, la personne peut se connecter avec le mot de passe qu'elle a choisi.
Combien de temps un lien d'e-mail Supabase reste-t-il valable ?
Une heure par défaut. Le réglage Email OTP Expiration le contrôle, et ce même réglage couvre les magic links, les confirmations d'inscription, les réinitialisations de mot de passe, les changements d'e-mail et les invitations. Supabase déconseille fortement toute valeur au-delà de 86 400 secondes, soit un jour.
Faut-il rendre les liens valables plus longtemps ?
Non. Un filtre de messagerie ouvre le lien à l'arrivée du message, donc une validité plus longue ne l'empêche pas de s'en servir le premier. Elle garde seulement chaque lien inutilisé de chaque boîte de réception actif plus longtemps, et un lien connecte quiconque l'ouvre, y compris quelqu'un à qui l'e-mail a été transféré.
Pourquoi le lien marche-t-il sur mon ordinateur et échoue-t-il sur mon téléphone ?
Si votre app utilise le flux PKCE, le navigateur où la personne a demandé le lien garde la moitié de ce dont la connexion a besoin, donc ouvrir l'e-mail sur un autre appareil échoue avec "PKCE code verifier not found in storage". Cette tentative consomme aussi le lien, et c'est pourquoi cliquer de nouveau sur le premier appareil affiche ensuite "Email link is invalid or has expired".
Que veut dire error_code=otp_expired dans l'adresse de mon app ?
C'est ainsi que Supabase Auth signale un lien d'e-mail qui a échoué. Il envoie la personne vers votre app avec error=access_denied, error_code=otp_expired et le message dans la partie de l'adresse placée après le signe #. Votre app peut y lire error_code et afficher une page qui propose d'envoyer un nouveau lien.