Vai al contenuto

Basi della sicurezza

Supabase "Email link is invalid or has expired" su un link nuovo

Su un link inviato un minuto fa, "Email link is invalid or has expired" di Supabase di solito vuol dire che un filtro della posta l'ha usato prima.

Vlad Tkachenko13 min di lettura
Una busta aperta da cui sporge a metà un biglietto, e il biglietto ha già un foro di obliterazione.

In breve

  • Supabase mostra "Email link is invalid or has expired" quando il token di un link di accesso o di conferma è già stato usato, è stato sostituito da un'email più recente o ha più di un'ora. Il codice di errore è otp_expired.
  • Su un link che la persona ha appena ricevuto, la causa abituale è un filtro della posta che ha aperto il link per controllarlo e così facendo lo ha consumato. La pagina di risoluzione dei problemi di Supabase lo indica come il motivo più comune.
  • Allungare la validità non cambia nulla. Cambia l'email in modo che aprire l'indirizzo non faccia niente: invia un codice da digitare, oppure un link a una pagina dove un pulsante fa entrare la persona.

Un nuovo utente si registra, apre l'email di conferma un minuto dopo e preme il pulsante. La tua app si apre sulla home, ancora senza accesso, e la barra degli indirizzi finisce così:

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

Ti registri con il tuo indirizzo per controllare. Il link funziona al primo colpo, e a ogni tentativo successivo.

Supabase dice "Email link is invalid or has expired" quando il token di un link email è già stato usato, è stato sostituito o ha superato la sua ora. Su un link arrivato un minuto fa, il motivo abituale è che qualcosa l'ha usato prima del tuo utente: un filtro della posta che apre i link per controllarli. La tua app fatta con Lovable, Bolt, v0, Cursor, Claude Code o Replit funziona come è stata costruita, e l'email che invia si può cambiare in modo che un filtro non abbia più niente da consumare.

Ecco la parte che il consiglio abituale sbaglia: "chiedigli di riprovare" e "fai durare di più il link" falliscono entrambi davanti a un filtro. Un secondo link passa per lo stesso filtro e finisce allo stesso modo, per quanto sarebbe durato.

Significa che Supabase Auth, la parte di Supabase che fa accedere le persone, non ha trovato dietro il link un token che fosse disposto ad accettare.

Il messaggio è scritto nel codice stesso del server Auth. Nella funzione che controlla un link email, viene restituito in due punti: quando nessun token in attesa corrisponde a quello del link, e quando il token che corrisponde è stato inviato da più tempo di quanto il tuo progetto consenta. Entrambe le risposte sono HTTP 403 con il codice otp_expired, quindi il messaggio da solo non ti dice quale dei due casi si è verificato.

Il tuo utente non vede mai il 403. Il link nell'email punta all'indirizzo del tuo progetto Supabase, che controlla il token e poi rimanda il browser alla tua app. Quando il controllo fallisce, Supabase mette tre cose nella parte dell'indirizzo della tua app che segue il #: error=access_denied, error_code=otp_expired e il messaggio stesso. Se la tua app è stata configurata con il flusso PKCE, si trovano anche nella parte che segue il ?. Finché nessuna pagina della tua app le legge, la persona vede quello che la tua app mostra a un visitatore senza accesso, senza una parola sul perché. Una pagina di reimpostazione della password che mostra comunque il suo modulo fallisce al salvataggio con "Auth session missing!", perché il link non ha fatto entrare nessuno, e quel messaggio ha un articolo suo.

Pensa al link come a un biglietto che fa entrare una persona, una volta sola. Chi lo consegna per primo passa, e il biglietto viene forato. "Email link is invalid or has expired" è la persona alla porta con in mano un biglietto che ha già un foro, o uno il cui orario è passato. Se i tuoi utenti entrano e più tardi vengono buttati fuori, è un altro errore, con una pagina tutta sua.

Perché qualcosa l'ha aperto prima del tuo utente, e aprire un link email di Supabase equivale a usarlo.

La guida ai modelli di email di Supabase lo elenca tra le sue limitazioni: alcuni provider di posta hanno il rilevamento dello spam o altre funzioni di sicurezza che aprono in anticipo i link delle email in arrivo. L'esempio che fa è Safe Links in Microsoft Defender for Office 365. La pagina di Supabase per risolvere questo errore indica questa apertura anticipata come il motivo più comune per cui un token sembra scaduto o non valido prima ancora che l'utente possa usarlo.

Il link predefinito non ha nessun passaggio tra l'apertura e l'uso. Il suo indirizzo è l'endpoint di verifica del tuo progetto, che controlla il token e lo consuma alla prima visita, chiunque la faccia. Così il filtro consegna il biglietto mentre arriva il messaggio, e passa. Il tuo utente clicca un minuto dopo e viene respinto alla porta.

La prima visita usa il link. Un filtro della posta che lo apre per controllarlo arriva prima della persona.

Da qui seguono due cose.

  • Tu non lo vedrai. Succede alle persone la cui posta passa da un filtro di quel tipo, e la tua casella potrebbe non farne mai parte. Le tue registrazioni di prova continuano a funzionare mentre i tuoi utenti ti scrivono.
  • L'account di solito è già confermato. In una conferma di registrazione, la visita del filtro conta: Supabase conferma l'indirizzo di chiunque apra il link. Se l'ha aperto un filtro, l'account è confermato quando il tuo utente vede l'errore, e può accedere con la password che ha scelto.

Le altre tre cause

Ognuna produce lo stesso messaggio, e ognuna ti chiede una cosa diversa.

Il link è già stato usato. Il tuo utente l'ha premuto una volta, magari sul telefono, e ora preme la stessa email sul portatile. Quando un link funziona, Supabase cancella tutti i link ancora in attesa per quell'account. Il suo codice degli account fa la stessa pulizia dopo una conferma e dopo un link di accesso, quindi un secondo clic non trova più niente. Il biglietto è stato forato alla prima visita.

Un'email più recente l'ha sostituito. Premere "invia di nuovo" crea un link nuovo, e Supabase cancella quello vecchio dello stesso tipo prima di salvarlo. Succede nel codice che crea il token. La prima email resta nella casella con un aspetto normale, e il suo link fallisce. Funziona solo l'email più recente. Supabase inoltre permette a un indirizzo di chiedere questo tipo di email solo una volta ogni 60 secondi, cosa che il limite delle email tratta per intero.

È scaduto davvero. La guida all'accesso senza password di Supabase dice che i magic link scadono per impostazione predefinita dopo 1 ora. La stessa pagina dice che l'impostazione Email OTP Expiration regola anche la validità dei magic link e degli altri link email, compresi quelli di conferma, recupero della password, cambio di email e invito. Chi si registra la sera e apre l'email la mattina dopo trova questo messaggio, qualunque cosa faccia il suo servizio di posta.

Se la tua app usa il flusso PKCE, l'altro dispositivo fallisce prima con un messaggio diverso, e questo arriva dopo.

PKCE divide il biglietto in due. La guida a PKCE di Supabase spiega che il code verifier viene creato e salvato in locale quando parte il flusso di Auth, e che quindi lo scambio del codice deve partire nello stesso browser e sullo stesso dispositivo in cui il flusso è iniziato. L'email porta una metà. Il browser in cui il tuo utente ha chiesto il link tiene la matrice.

Aperto su un telefono, il link passa comunque per il controllo di Supabase, che lo consuma. Poi la tua app cerca la matrice sul telefono e non la trova. Il client JavaScript di Supabase lo segnala con parole sue:

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.

Così la persona torna al portatile e preme lì la stessa email, e Supabase risponde "Email link is invalid or has expired", perché il telefono ha già fatto forare il biglietto. Se l'indirizzo su cui si apre la tua app dopo un link che funziona contiene ?code=, la tua app usa PKCE e questo caso ti riguarda.

Come capisco quale delle cause è?

Chiedilo al tuo database. Ogni account del tuo progetto è una riga in una tabella chiamata auth.users, e quella riga registra quando è stato inviato ogni tipo di email e quando è stato confermato l'indirizzo.

Apri il SQL Editor nella tua dashboard di Supabase ed esegui questo con l'indirizzo che ti ha dato la persona:

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

Legge una riga e non cambia nulla. confirmation_sent_at è quando è partita l'ultima conferma di registrazione. recovery_sent_at è quando è partito l'ultimo magic link o ripristino della password verso un account che esisteva già. Sono i momenti da cui Supabase misura l'ora, nella stessa funzione che restituisce l'errore. Confrontali con l'orario dell'email che la persona ha premuto.

Cosa vediCosa significa
Un link di conferma è fallito al primo clic, e email_confirmed_at è compilatoQualcosa ha aperto il link prima della persona. L'account è confermato, quindi può accedere con la sua password
Un link di conferma è fallito, email_confirmed_at è vuoto, e l'ha premuto più di un'ora dopo l'invioÈ scaduto. Invia una nuova conferma
confirmation_sent_at o recovery_sent_at è successivo all'email che ha premutoUn'email più recente l'ha sostituito. Funziona solo il link della più nuova
Un magic link dell'email più recente è fallito al primo clic, entro l'oraQualcosa l'ha aperto prima: un filtro della posta, o la persona su un altro dispositivo

Se la riga mostra l'indirizzo confermato e la persona continua a non riuscire a entrare con una password, Invalid login credentials è il prossimo messaggio da leggere.

Cosa non cambiare mentre lo sistemi

La validità, l'interruttore Confirm email e chi può confermare un utente. Ognuna delle scorciatoie ovvie fa smettere il messaggio e apre qualcosa.

Lascia la validità a un'ora. Un filtro apre il link mentre arriva il messaggio, quindi anche un link che dura una settimana viene usato prima da lui. La guida all'accesso senza password di Supabase sconsiglia esplicitamente più di 86.400 secondi, cioè un giorno. Un link di accesso fa entrare chiunque lo apra. Più a lungo funziona, più a lungo un'email inoltrata, una casella condivisa o un vecchio messaggio nell'archivio di qualcuno sono una via d'accesso a quell'account.

Lascia attivo Confirm email. Se lo disattivi, le registrazioni non inviano più nessun link e questo errore smette. Vuol dire anche che chiunque può creare un account con un indirizzo che non ha mai controllato.

Non confermare gli utenti dal browser. Chiedi a un assistente di "segnarli come confermati e basta" e potrebbe ricorrere alle funzioni di amministrazione di Supabase, che possono impostare email_confirm su un account. Il riferimento di amministrazione di Supabase dice che ogni metodo sotto supabase.auth.admin richiede una chiave secret e che quella chiave non va mai esposta nel browser. Una chiave segreta nel tuo frontend legge e scrive ogni tabella del tuo database, qualunque cosa dicano le tue regole, per chiunque apra il codice del tuo sito. È la chiave che questo articolo sulle chiavi API ti dice di ruotare. Se non sei sicuro che non ne sia scivolata dentro una, la nostra scansione gratuita legge il codice che la tua app online invia a ogni visitatore e segnala le chiavi segrete che ci trova. Ci mette circa 20 secondi e non chiede un account: scansiona la tua app.

Controlla ogni indirizzo che la tua pagina di conferma passa avanti. La seconda soluzione proposta da Supabase è una pagina tua con un pulsante, raggiunta con un link di questa forma:

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

Il pulsante porta poi dove dice confirmation_url. Costruita senza controllo, quella pagina manda chiunque segua un link manomesso a un indirizzo qualsiasi, con il nome della tua app. O controlli che l'indirizzo cominci con quello del tuo progetto, https://<project-ref>.supabase.co/auth/v1/verify, oppure usi la versione con il token della sezione successiva, che non fa passare nessun indirizzo dalla pagina.

Fai in modo che aprire l'indirizzo non faccia nulla, così entra solo una persona che digita un codice o preme un pulsante. Supabase documenta entrambe le soluzioni, e le cambi nei modelli di email, sotto Authentication nella tua dashboard. Confirm signup e Magic Link sono i due da modificare.

Invia un codice. Metti {{ .Token }} nel modello. La guida di Supabase lo descrive come una password monouso di 6 cifre che si può usare al posto del link. La tua pagina chiede l'indirizzo email e il codice, poi chiama:

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

Un filtro può aprire un'email. Non può digitare un codice nella tua pagina.

Collega una pagina con un pulsante. Fai puntare il link a una pagina tua e porta con sé l'hash del token, così non deve passare nessun indirizzo:

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

{{ .SiteURL }} è la Site URL della pagina URL Configuration del tuo progetto. Se dice ancora http://localhost:3000, correggi prima quella.

La pagina mostra un pulsante, e solo il pulsante fa il lavoro:

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

Il tipo email copre entrambi i modelli: il controllo di Supabase cerca l'hash sia tra le conferme di registrazione sia tra i link di accesso.

La pagina non deve chiamare verifyOtp al caricamento. La guida alle password di Supabase costruisce il suo link di conferma come {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }} e lo verifica nel momento in cui quell'indirizzo viene aperto. È di nuovo il link predefinito a un indirizzo nuovo, e un filtro lo apre con la stessa facilità.

Con il link predefinito, aprirlo fa già entrare. Con un pulsante o un codice digitato, lo fa solo l'azione della persona.

Qualunque cosa cambi, qualcuno troverà ancora un link vecchio, quindi dagli una via d'uscita. La pagina su cui arrivano i tuoi link può leggere l'errore e offrire una nuova email:

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

Per una registrazione, aggiungi una riga che dica che forse è già confermata e può provare ad accedere. Se è un builder a scrivere le tue pagine di accesso, chiedigli la versione con codice o con pulsante di entrambi i modelli, una pagina di arrivo che legga otp_expired e offra un link nuovo, e nessuna chiave di amministrazione da nessuna parte nell'app.

Sapere dei problemi prima che i tuoi utenti ti scrivano

Questo errore raggiunge i tuoi utenti e mai te, e anche altri guasti arrivano prima a loro: l'app che cade di notte, o un prompt successivo che mette una chiave nel codice della tua app.

Reeve Monitor continua a controllare la tua app online dall'esterno:

  • se risponde, ogni 60 secondi, così "il link non funziona" si distingue da "il sito è giù"
  • tutti e nove i controlli ogni ora, su un massimo di tre app, comprese le chiavi segrete nel codice che la tua app invia ai visitatori
  • un messaggio quando un risultato cambia, così una chiave uscita con un deploy successivo ti raggiunge senza che tu debba andare a guardare
  • un report mensile, in linguaggio semplice, di quello che ha visto

Reeve Care tiene una copia del tuo database Supabase, account compresi:

  • una copia cifrata secondo una pianificazione, sullo spazio di archiviazione di Reeve nell'UE, fuori dal tuo account Supabase
  • lo schema auth in cui vivono gli account dei tuoi utenti, così tornano insieme a tutto il resto
  • ogni copia verificata prima che conti, contando le righe di ogni tabella
  • anche i tuoi file caricati, appena colleghi una credenziale di Storage
  • un ripristino in un clic, con una copia di sicurezza dello stato attuale presa prima
  • tutto quello che fa Monitor

Cosa fare adesso

Cosa fare

  • Leggi la riga della persona in auth.users. Un email_confirmed_at compilato su una conferma fallita vuol dire che un filtro ha aperto il link e che può accedere.
  • Di' alle persone di usare l'email più recente e, se la tua app usa PKCE, di aprirla sul dispositivo dove l'hanno chiesta.
  • Passa i modelli Confirm signup e Magic Link a un codice, o a una pagina dove un pulsante chiama verifyOtp.
  • Fai leggere error_code=otp_expired alla pagina su cui arrivano i tuoi link, e falle offrire un link nuovo.
  • Lascia la validità a un'ora, lascia attivo Confirm email e tieni la chiave segreta fuori dal browser.

Se le email non arrivano proprio, il limite delle email è la prima cosa da escludere. La checklist di sicurezza in 10 minuti copre ciò che di solito resta aperto in un'app appena lanciata.

FAQ

Perché un magic link di Supabase dice che è scaduto subito dopo l'invio?

Di solito perché qualcosa l'ha aperto prima della persona. Alcuni servizi di posta aprono i link di un messaggio in arrivo per controllarli, e Supabase cita come esempio Safe Links in Microsoft Defender for Office 365. Aprire un link email di Supabase significa usarlo, quindi quando la persona clicca un minuto dopo il token non c'è più e Supabase risponde "Email link is invalid or has expired".

L'account è confermato anche se il link diceva che era scaduto?

Se un filtro della posta ha aperto per primo un link di conferma della registrazione, sì. Supabase conferma l'indirizzo per chiunque apra il link, quindi la visita del filtro l'ha confermato. Guarda email_confirmed_at in auth.users: se è compilato, la persona può accedere con la password che ha scelto.

Per quanto tempo resta valido un link email di Supabase?

Un'ora per impostazione predefinita. Lo controlla l'impostazione Email OTP Expiration, e la stessa impostazione copre i magic link, le conferme di registrazione, i ripristini della password, i cambi di email e gli inviti. Supabase sconsiglia esplicitamente qualsiasi valore oltre 86.400 secondi, cioè un giorno.

Devo far durare di più i link?

No. Un filtro della posta apre il link quando arriva il messaggio, quindi una validità più lunga non gli impedisce di usarlo per primo. Serve solo a tenere attivo più a lungo ogni link non usato in ogni casella di posta, e un link fa entrare chiunque lo apra, compresa una persona a cui l'email è stata inoltrata.

Perché il link funziona sul mio computer e non sul telefono?

Se la tua app usa il flusso PKCE, il browser in cui la persona ha chiesto il link conserva metà di ciò che serve per l'accesso, quindi aprire l'email su un altro dispositivo fallisce con "PKCE code verifier not found in storage". Quel tentativo consuma anche il link, ed è per questo che cliccarlo di nuovo sul primo dispositivo poi dice "Email link is invalid or has expired".

Che cosa significa error_code=otp_expired nell'indirizzo della mia app?

È il modo in cui Supabase Auth segnala un link email fallito. Manda la persona alla tua app con error=access_denied, error_code=otp_expired e il messaggio nella parte dell'indirizzo che segue il segno #. La tua app può leggere error_code lì e mostrare una pagina che offre di inviare un nuovo link.

Scritto da

Vlad Tkachenko

Fondatore di Reeve

Passo le giornate a guardare app costruite con Lovable, Bolt, v0, Cursor e Replit, e la breve lista di errori che vi ricompaiono di continuo.

Altro sull'autore

Da leggere dopo

Tutti gli articoli

Fai il backup del database Supabase prima della prossima modifica

Reeve ne tiene una copia cifrata a intervalli programmati e la ripristina per te se qualcosa va storto.

Vedi i backup Supabase

Controllo esterno automatizzato, non un audit completo. L'assenza di risultati non è una garanzia di sicurezza.