Zum Inhalt springen

Sicherheitsgrundlagen

Supabase "Email link is invalid or has expired" bei einem neuen Link

Bei einem Link von vor einer Minute heißt Supabases "Email link is invalid or has expired" meist: Ein Mailfilter hat ihn zuerst benutzt. Prüfen und beheben.

Vlad Tkachenko13 Min. Lesezeit
Ein offener Umschlag, aus dem ein Ticket halb herausragt, und durch das Ticket ist schon ein Loch gestanzt.

Kurz gesagt

  • Supabase zeigt "Email link is invalid or has expired", wenn das Token in einem Anmelde- oder Bestätigungslink schon benutzt wurde, durch eine neuere E-Mail ersetzt wurde oder älter als eine Stunde ist. Der Fehlercode ist otp_expired.
  • Bei einem Link, den die Person gerade erst bekommen hat, ist die übliche Ursache ein Mailfilter, der den Link zur Prüfung geöffnet und ihn dabei verbraucht hat. Supabases eigene Seite zur Fehlersuche nennt das den häufigsten Grund.
  • Eine längere Gültigkeit ändert nichts. Ändere die E-Mail so, dass das Öffnen der Adresse nichts auslöst: Schick einen Code zum Eintippen oder verlinke eine Seite, auf der ein Knopf die Anmeldung erledigt.

Ein neuer Nutzer registriert sich, öffnet die Bestätigungs-E-Mail eine Minute später und drückt auf den Knopf. Deine App öffnet sich auf der Startseite, immer noch abgemeldet, und die Adresszeile endet so:

#error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired

Du registrierst dich zur Probe mit deiner eigenen Adresse. Der Link funktioniert beim ersten Mal und bei jedem weiteren Versuch.

Supabase sagt "Email link is invalid or has expired", wenn das Token in einem E-Mail-Link schon benutzt wurde, ersetzt wurde oder seine Stunde überschritten hat. Bei einem Link, der vor einer Minute ankam, ist der übliche Grund, dass etwas ihn vor deinem Nutzer benutzt hat: ein Mailfilter, der Links öffnet, um sie zu prüfen. Deine App aus Lovable, Bolt, v0, Cursor, Claude Code oder Replit arbeitet so, wie sie gebaut ist, und die E-Mail, die sie verschickt, lässt sich so ändern, dass ein Filter nichts mehr verbrauchen kann.

Hier liegt der Teil, den der übliche Rat falsch macht: "Bitte sie, es noch einmal zu versuchen" und "mach den Link länger gültig" scheitern beide an einem Filter. Ein zweiter Link läuft durch denselben Filter und endet genauso, egal wie lange er gegolten hätte.

Supabase Auth, der Teil von Supabase, der Leute anmeldet, hat hinter dem Link kein Token gefunden, das es annehmen wollte.

Die Meldung steht im Code des Auth-Servers selbst. In der Funktion, die einen E-Mail-Link prüft, kommt sie an zwei Stellen zurück: wenn kein wartendes Token zu dem im Link passt, und wenn das passende Token vor längerer Zeit verschickt wurde, als dein Projekt erlaubt. Beide Antworten sind HTTP 403 mit dem Code otp_expired, die Meldung allein sagt dir also nicht, welcher der beiden Fälle eingetreten ist.

Dein Nutzer sieht den 403 nie. Der Link in der E-Mail zeigt auf die eigene Adresse deines Supabase-Projekts, die das Token prüft und den Browser dann zu deiner App weiterschickt. Scheitert die Prüfung, setzt Supabase drei Dinge in den Teil der Adresse deiner App hinter dem #: error=access_denied, error_code=otp_expired und die Meldung selbst. Wurde deine App mit dem PKCE-Ablauf eingerichtet, stehen sie zusätzlich im Teil hinter dem ?. Solange keine Seite deiner App sie liest, sieht die Person das, was deine App einem nicht angemeldeten Besucher zeigt, ohne ein Wort zum Warum. Eine Seite zum Zurücksetzen des Passworts, die ihr Formular trotzdem zeigt, scheitert beim Speichern mit "Auth session missing!", weil der Link niemanden angemeldet hat, und diese Meldung hat einen eigenen Beitrag.

Stell dir den Link als Ticket vor, das eine Person einmal einlässt. Wer es zuerst abgibt, kommt durch, und das Ticket wird gelocht. "Email link is invalid or has expired" ist die Person an der Tür, die ein Ticket hochhält, in dem schon ein Loch ist, oder eines, dessen Zeit abgelaufen ist. Wenn deine Nutzer hineinkommen und später hinausgeworfen werden, ist das ein anderer Fehler mit einer eigenen Seite.

Weil etwas ihn geöffnet hat, bevor dein Nutzer es tat, und einen E-Mail-Link von Supabase zu öffnen ist dasselbe, wie ihn zu benutzen.

Supabases Anleitung zu den E-Mail-Vorlagen führt das unter ihren Einschränkungen auf: Manche E-Mail-Anbieter haben Spamerkennung oder andere Sicherheitsfunktionen, die Links in eingehenden E-Mails vorab abrufen. Als Beispiel nennt sie Safe Links in Microsoft Defender for Office 365. Supabases Seite zur Fehlersuche bei diesem Fehler nennt dieses Vorabrufen den häufigsten Grund dafür, dass ein Token abgelaufen oder ungültig wirkt, bevor ein Nutzer es überhaupt benutzen kann.

Der Standardlink hat keinen Schritt zwischen Öffnen und Benutzen. Seine Adresse ist der Verify-Endpunkt deines Projekts, der das Token prüft und beim ersten Besuch verbraucht, egal wer ihn macht. Der Filter gibt das Ticket also ab, während die Nachricht ankommt, und es geht durch. Dein Nutzer klickt eine Minute später und wird an der Tür abgewiesen.

Der erste Besuch benutzt den Link. Ein Mailfilter, der ihn zur Prüfung öffnet, ist vor der Person da.

Daraus folgen zwei Dinge.

  • Du siehst es selbst nicht. Es trifft Menschen, deren Mail durch einen solchen Filter läuft, und dein eigenes Postfach gehört vielleicht nie dazu. Deine Test-Registrierungen funktionieren weiter, während deine Nutzer dir schreiben.
  • Das Konto ist meist schon bestätigt. Bei einer Bestätigung der Registrierung zählt der Besuch des Filters: Supabase bestätigt die Adresse für den, der den Link öffnet. Hat ein Filter ihn geöffnet, ist das Konto bestätigt, wenn dein Nutzer den Fehler sieht, und er kann sich mit dem Passwort anmelden, das er gewählt hat.

Die drei anderen Ursachen

Jede davon erzeugt dieselbe Meldung, und jede verlangt etwas anderes von dir.

Der Link wurde schon benutzt. Dein Nutzer hat ihn einmal gedrückt, vielleicht auf dem Handy, und drückt jetzt dieselbe E-Mail auf dem Laptop. Wenn ein Link funktioniert, löscht Supabase jeden Link, der für dieses Konto noch wartet. Sein Code für Konten führt dieselbe Bereinigung nach einer Bestätigung und nach einem Anmeldelink aus, also findet ein zweiter Klick nichts mehr. Das Ticket wurde beim ersten Besuch gelocht.

Eine neuere E-Mail hat ihn ersetzt. Ein Druck auf "erneut senden" erzeugt einen neuen Link, und Supabase löscht den älteren derselben Art, bevor es den neuen speichert. Das passiert in dem Code, der das Token anlegt. Die erste E-Mail liegt weiter unauffällig im Postfach, und ihr Link scheitert. Nur die neueste E-Mail funktioniert. Supabase lässt eine Adresse außerdem nur alle 60 Sekunden nach dieser Art E-Mail fragen, was das E-Mail-Limit ausführlich behandelt.

Er ist wirklich abgelaufen. Supabases Anleitung zur Anmeldung ohne Passwort sagt, dass Magic Links standardmäßig nach 1 Stunde ablaufen. Auf derselben Seite steht, dass die Einstellung Email OTP Expiration auch die Gültigkeit von Magic Links und anderen E-Mail-Links regelt, darunter Bestätigung, Passwort-Wiederherstellung, E-Mail-Änderung und Einladung. Wer sich abends registriert und die E-Mail am nächsten Morgen öffnet, trifft auf diese Meldung, egal was sein Mail-Dienst tut.

Nutzt deine App den PKCE-Ablauf, scheitert das andere Gerät zuerst mit einer anderen Meldung, und diese folgt danach.

PKCE teilt das Ticket in zwei Teile. Supabases PKCE-Anleitung erklärt, dass der Code Verifier lokal erzeugt und gespeichert wird, wenn der Auth-Ablauf beginnt, und dass der Code-Austausch deshalb im selben Browser und auf demselben Gerät angestoßen werden muss, auf dem der Ablauf begonnen hat. Die E-Mail trägt die eine Hälfte. Der Browser, in dem dein Nutzer den Link angefordert hat, behält den Abschnitt.

Auf einem Handy geöffnet, läuft der Link trotzdem durch Supabases Prüfung, die ihn verbraucht. Dann sucht deine App auf dem Handy nach dem Abschnitt und findet keinen. Supabases JavaScript-Client meldet das mit eigenen Worten:

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.

Also geht die Person zurück an den Laptop und drückt dort dieselbe E-Mail, und Supabase antwortet "Email link is invalid or has expired", weil das Handy das Ticket schon hat lochen lassen. Wenn die Adresse, auf der sich deine App nach einem funktionierenden Link öffnet, ?code= enthält, nutzt deine App PKCE, und dieser Fall betrifft dich.

Wie finde ich heraus, welcher Fall es war?

Frag deine eigene Datenbank. Jedes Konto in deinem Projekt ist eine Zeile in einer Tabelle namens auth.users, und diese Zeile hält fest, wann jede Art E-Mail verschickt und wann die Adresse bestätigt wurde.

Öffne den SQL Editor in deinem Supabase-Dashboard und führ das mit der Adresse aus, die dir die Person genannt hat:

select email,
       confirmation_sent_at,
       email_confirmed_at,
       recovery_sent_at
from auth.users
where lower(email) = lower('person@example.com');

Die Abfrage liest eine Zeile und ändert nichts. confirmation_sent_at ist der Zeitpunkt, zu dem die letzte Bestätigung der Registrierung hinausging. recovery_sent_at ist der Zeitpunkt, zu dem der letzte Magic Link oder das letzte Zurücksetzen des Passworts an ein Konto ging, das es schon gab. Ab diesen Zeitpunkten misst Supabase die Stunde, in derselben Funktion, die den Fehler zurückgibt. Vergleich sie mit der Uhrzeit der E-Mail, die die Person gedrückt hat.

Was du siehstWas es bedeutet
Ein Bestätigungslink ist beim ersten Druck gescheitert, und email_confirmed_at ist gefülltEtwas hat den Link vor der Person geöffnet. Das Konto ist bestätigt, sie kann sich also mit ihrem Passwort anmelden
Ein Bestätigungslink ist gescheitert, email_confirmed_at ist leer, und sie hat über eine Stunde nach dem Versand gedrücktEr ist abgelaufen. Schick eine neue Bestätigung
confirmation_sent_at oder recovery_sent_at liegt später als die E-Mail, die sie gedrückt hatEine neuere E-Mail hat sie ersetzt. Nur der Link in der neuesten funktioniert
Ein Magic Link aus der neuesten E-Mail ist beim ersten Druck gescheitert, innerhalb der StundeEtwas hat ihn zuerst geöffnet: ein Mailfilter oder die Person auf einem anderen Gerät

Zeigt die Zeile die Adresse als bestätigt und die Person kommt trotzdem nicht mit einem Passwort hinein, ist Invalid login credentials die nächste Meldung, über die du lesen solltest.

Was du nicht ändern solltest, während du das behebst

Die Gültigkeit, den Schalter Confirm email und wer einen Nutzer bestätigen darf. Jede der naheliegenden Abkürzungen hier stoppt die Meldung und öffnet etwas.

Lass die Gültigkeit bei einer Stunde. Ein Filter öffnet den Link, während die Nachricht ankommt, also wird auch ein Link, der eine Woche hält, zuerst von ihm benutzt. Supabases Anleitung zur Anmeldung ohne Passwort rät ausdrücklich von mehr als 86.400 Sekunden, also einem Tag, ab. Ein Anmeldelink meldet jeden an, der ihn öffnet. Je länger er funktioniert, desto länger ist eine weitergeleitete E-Mail, ein geteiltes Postfach oder eine alte Nachricht in jemandes Archiv ein Weg in dieses Konto.

Lass Confirm email an. Schaltest du es aus, verschicken Registrierungen gar keinen Link mehr, und dieser Fehler hört auf. Es heißt aber auch, dass jeder ein Konto mit einer Adresse anlegen kann, die ihm nie gehört hat.

Bestätige Nutzer nicht aus dem Browser. Bittest du einen Assistenten, "sie einfach als bestätigt zu markieren", greift er vielleicht zu den Admin-Funktionen von Supabase, die email_confirm an einem Konto setzen können. Supabases Admin-Referenz sagt, dass jede Methode unter supabase.auth.admin einen secret-Schlüssel braucht und dass dieser Schlüssel nie im Browser auftauchen darf. Ein geheimer Schlüssel in deinem Frontend liest und schreibt jede Tabelle deiner Datenbank, egal was deine Regeln sagen, für jeden, der den Code deiner Seite öffnet. Das ist der Schlüssel, den dieser Artikel über API-Schlüssel dir zu rotieren rät. Wenn du nicht sicher bist, ob einer hineingerutscht ist: Unser kostenloser Scan liest den Code, den deine Live-App an jeden Besucher schickt, und meldet die geheimen Schlüssel, die er darin findet. Er dauert etwa 20 Sekunden und braucht kein Konto: scanne deine App.

Prüf jede Adresse, die deine Bestätigungsseite weitergibt. Supabases zweite vorgeschlagene Lösung ist eine eigene Seite mit einem Knopf, erreicht über einen Link dieser Form:

<a href="{{ .SiteURL }}/confirm-signup?confirmation_url={{ .ConfirmationURL }}">

Der Knopf führt dann dorthin, wohin confirmation_url sagt. Ohne Prüfung gebaut, schickt diese Seite jeden, der einem präparierten Link folgt, an eine beliebige Adresse, unter dem Namen deiner App. Prüf entweder, dass die Adresse mit der deines eigenen Projekts beginnt, https://<project-ref>.supabase.co/auth/v1/verify, oder nimm die Variante mit dem Token aus dem nächsten Abschnitt, die gar keine Adresse durch die Seite reicht.

Sorg dafür, dass das Öffnen der Adresse nichts auslöst, sodass nur eine Person, die einen Code eintippt oder einen Knopf drückt, sich anmeldet. Supabase dokumentiert beides, und du änderst es in den E-Mail-Vorlagen unter Authentication in deinem Dashboard. Confirm signup und Magic Link sind die beiden, die du bearbeitest.

Schick einen Code. Setz {{ .Token }} in die Vorlage. Supabases Anleitung beschreibt es als ein Einmalpasswort mit 6 Ziffern, das statt des Links benutzt werden kann. Deine Seite fragt nach der E-Mail-Adresse und dem Code und ruft dann auf:

const { error } = await supabase.auth.verifyOtp({ email, token, type: 'email' })

Ein Filter kann eine E-Mail öffnen. Einen Code in deine Seite tippen kann er nicht.

Verlinke eine Seite mit einem Knopf. Lass den Link auf eine Seite von dir zeigen und den Hash des Tokens mitnehmen, damit keine Adresse durchgereicht werden muss:

<a href="{{ .SiteURL }}/confirm?token_hash={{ .TokenHash }}&type=email">Confirm your email</a>

{{ .SiteURL }} ist die Site URL auf der Seite URL Configuration deines Projekts. Steht dort noch http://localhost:3000, korrigier das zuerst.

Die Seite zeigt einen Knopf, und nur der Knopf erledigt die Arbeit:

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()
}

Der Typ email deckt beide Vorlagen ab: Supabases Prüfung sucht den Hash unter Bestätigungen der Registrierung und Anmeldelinks gleichermaßen.

Die Seite darf verifyOtp nicht beim Laden aufrufen. Supabases Anleitung zu Passwörtern baut ihren Bestätigungslink als {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }} und prüft ihn in dem Moment, in dem diese Adresse geöffnet wird. Das ist wieder der Standardlink unter einer neuen Adresse, und ein Filter öffnet ihn genauso leicht.

Beim Standardlink meldet schon das Öffnen an. Mit einem Knopf oder einem eingetippten Code tut es nur die Handlung der Person.

Was du auch änderst, manche Leute treffen weiter auf einen alten Link, also gib ihnen einen Weg weiter. Die Seite, auf der deine Links landen, kann den Fehler lesen und eine neue E-Mail anbieten:

const hash = new URLSearchParams(window.location.hash.slice(1))
if (hash.get('error_code') === 'otp_expired') showSendNewLink()

Bei einer Registrierung schreib dazu, dass die Person vielleicht schon bestätigt ist und sich anzumelden versuchen kann. Wenn ein Builder deine Anmeldeseiten schreibt, bitte ihn um die Code- oder Knopf-Variante beider Vorlagen, eine Landeseite, die otp_expired liest und einen frischen Link anbietet, und keinen Admin-Schlüssel irgendwo in der App.

Von Problemen hören, bevor deine Nutzer dir schreiben

Dieser Fehler erreicht deine Nutzer und nie dich, und andere Ausfälle erreichen sie ebenfalls zuerst: die App, die nachts ausfällt, oder ein späterer Prompt, der einen Schlüssel in den Code deiner App bringt.

Reeve Monitor prüft deine Live-App weiter von außen:

  • ob sie erreichbar ist, alle 60 Sekunden, damit sich "der Link geht nicht" von "die Seite ist weg" unterscheiden lässt
  • alle neun Prüfungen jede Stunde, für bis zu drei Apps, einschließlich geheimer Schlüssel im Code, den deine App an Besucher schickt
  • eine Nachricht, wenn sich ein Ergebnis ändert, damit dich ein Schlüssel, der mit einem späteren Deploy hinausgeht, erreicht, ohne dass du nachsehen musst
  • ein monatlicher Bericht in klarer Sprache über das, was er gesehen hat

Reeve Care hält eine Kopie deiner Supabase-Datenbank, samt Konten:

  • eine verschlüsselte Kopie nach Zeitplan, auf Reeves eigenem Speicher in der EU, außerhalb deines Supabase-Kontos
  • das Auth-Schema, in dem die Konten deiner Nutzer liegen, damit sie mit allem anderen zurückkommen
  • jede Kopie geprüft, bevor sie zählt, durch Zählen der Zeilen in jeder Tabelle
  • auch deine hochgeladenen Dateien, sobald du einen Storage-Zugang verbindest
  • eine Wiederherstellung per Klick, mit einer Sicherheitskopie des aktuellen Stands vorab
  • alles, was Monitor tut

Was du jetzt tun solltest

Was zu tun ist

  • Lies die Zeile der Person in auth.users. Ein gefülltes email_confirmed_at bei einer gescheiterten Bestätigung heißt, dass ein Filter den Link geöffnet hat und sie sich anmelden kann.
  • Sag den Leuten, dass sie die neueste E-Mail nehmen sollen, und wenn deine App PKCE nutzt, dass sie sie auf dem Gerät öffnen, auf dem sie sie angefordert haben.
  • Stell die Vorlagen Confirm signup und Magic Link auf einen Code um oder auf eine Seite, auf der ein Knopf verifyOtp aufruft.
  • Lass die Seite, auf der deine Links landen, error_code=otp_expired lesen und einen neuen Link anbieten.
  • Lass die Gültigkeit bei einer Stunde, lass Confirm email an und halte den geheimen Schlüssel aus dem Browser heraus.

Wenn die E-Mails gar nicht erst ankommen, ist das E-Mail-Limit das Erste, was du ausschließen solltest. Die 10-Minuten-Sicherheits-Checkliste deckt ab, was in einer frisch gestarteten App sonst oft offen bleibt.

FAQ

Warum sagt ein Magic Link von Supabase gleich nach dem Versand, er sei abgelaufen?

Meist, weil etwas ihn vor der Person geöffnet hat. Manche E-Mail-Dienste öffnen die Links in einer eingehenden Nachricht, um sie zu prüfen, und Supabase nennt Safe Links in Microsoft Defender for Office 365 als Beispiel. Einen E-Mail-Link von Supabase zu öffnen heißt, ihn zu benutzen. Klickt die Person eine Minute später, ist das Token weg, und Supabase antwortet "Email link is invalid or has expired".

Ist das Konto bestätigt, obwohl der Link abgelaufen sein soll?

Wenn ein Mailfilter einen Bestätigungslink zur Registrierung zuerst geöffnet hat, ja. Supabase bestätigt die Adresse für den, der den Link öffnet, also hat der Besuch des Filters sie bestätigt. Sieh in auth.users nach email_confirmed_at: Ist das Feld gefüllt, kann sich die Person mit dem Passwort anmelden, das sie gewählt hat.

Wie lange ist ein E-Mail-Link von Supabase gültig?

Standardmäßig eine Stunde. Die Einstellung Email OTP Expiration regelt das, und dieselbe Einstellung gilt für Magic Links, Bestätigungen der Registrierung, das Zurücksetzen von Passwörtern, E-Mail-Änderungen und Einladungen. Von mehr als 86.400 Sekunden, also einem Tag, rät Supabase ausdrücklich ab.

Soll ich die Links länger gültig machen?

Nein. Ein Mailfilter öffnet den Link, sobald die Nachricht ankommt, also hält ihn eine längere Gültigkeit nicht davon ab, ihn zuerst zu benutzen. Sie sorgt nur dafür, dass jeder unbenutzte Link in jedem Postfach länger funktioniert, und ein Link meldet jeden an, der ihn öffnet, auch jemanden, an den die E-Mail weitergeleitet wurde.

Warum funktioniert der Link auf meinem Computer und auf dem Handy nicht?

Wenn deine App den PKCE-Ablauf nutzt, behält der Browser, in dem die Person den Link angefordert hat, die Hälfte dessen, was die Anmeldung braucht. Auf einem anderen Gerät scheitert die E-Mail deshalb mit "PKCE code verifier not found in storage". Dieser Versuch verbraucht den Link außerdem, und darum sagt ein erneuter Klick auf dem ersten Gerät danach "Email link is invalid or has expired".

Was bedeutet error_code=otp_expired in der Adresse meiner App?

So meldet Supabase Auth einen gescheiterten E-Mail-Link. Es schickt die Person weiter zu deiner App, mit error=access_denied, error_code=otp_expired und der Meldung im Teil der Adresse hinter dem Zeichen #. Deine App kann error_code dort lesen und eine Seite zeigen, die anbietet, einen neuen Link zu schicken.

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.