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.

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.
Che cosa significa "Email link is invalid or has expired" in Supabase?
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é un link inviato un minuto fa è già stato usato
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.
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.
Che cosa succede quando il link viene aperto su un altro dispositivo?
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 vedi | Cosa significa |
|---|---|
Un link di conferma è fallito al primo clic, e email_confirmed_at è compilato | Qualcosa 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 premuto | Un'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'ora | Qualcosa 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.
Come inviare un link che un filtro della posta non può consumare
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à.
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. Unemail_confirmed_atcompilato 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_expiredalla 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.