Noções de segurança
Supabase "Email link is invalid or has expired" num link novo
Num link enviado há um minuto, "Email link is invalid or has expired" no Supabase costuma significar que um filtro de e-mail o usou antes. Como resolver.

Em resumo
- O Supabase mostra "Email link is invalid or has expired" quando o token de um link de login ou de confirmação já foi usado, foi substituído por um e-mail mais recente ou tem mais de uma hora. O código de erro é otp_expired.
- Num link que a pessoa acabou de receber, a causa comum é um filtro de e-mail que abriu o link para verificá-lo e o gastou ao fazer isso. A própria página de solução de problemas do Supabase chama isso de motivo mais comum.
- Aumentar a validade não muda nada. Mude o e-mail para que abrir o endereço não faça nada: envie um código para digitar, ou um link para uma página onde um botão faz o login da pessoa.
Um usuário novo se cadastra, abre o e-mail de confirmação um minuto depois e aperta o botão. O seu app abre na página inicial, ainda sem login, e a barra de endereços termina assim:
#error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired
Você se cadastra com o seu próprio endereço para conferir. O link funciona de primeira, e em todas as tentativas depois.
O Supabase diz "Email link is invalid or has expired" quando o token de um link de e-mail já foi usado, foi substituído ou passou da sua hora. Num link que chegou há um minuto, o motivo comum é que alguma coisa o usou antes do seu usuário: um filtro de e-mail que abre os links para verificá-los. O seu app feito no Lovable, Bolt, v0, Cursor, Claude Code ou Replit está funcionando do jeito que foi construído, e o e-mail que ele envia pode ser mudado para que um filtro não tenha mais nada para gastar.
Esta é a parte que o conselho comum erra: "peça para tentar de novo" e "faça o link durar mais" falham os dois diante de um filtro. Um segundo link passa pelo mesmo filtro e acaba do mesmo jeito, por mais tempo que fosse durar.
O que significa "Email link is invalid or has expired" no Supabase?
Significa que o Supabase Auth, a parte do Supabase que faz o login das pessoas, não encontrou por trás do link um token que estivesse disposto a aceitar.
A mensagem está escrita no próprio código do servidor do Auth. Na
função que verifica um link de e-mail,
ela é devolvida em dois pontos: quando nenhum token pendente bate com o do
link, e quando o token que bate foi enviado há mais tempo do que o seu projeto
permite. As duas respostas são HTTP 403 com o código otp_expired, então a
mensagem sozinha não diz qual das duas aconteceu.
O seu usuário nunca vê o 403. O link do e-mail aponta para o endereço do seu
próprio projeto Supabase, que verifica o token e depois manda o navegador para
o seu app. Quando a verificação falha, o Supabase coloca três coisas na parte
do endereço do seu app que vem depois do #: error=access_denied,
error_code=otp_expired e a própria mensagem. Se o seu app foi configurado com
o fluxo PKCE, elas também aparecem na parte depois do ?. Enquanto nenhuma
página do seu app as ler, a pessoa vê o que o seu app mostra a um visitante sem
login, sem uma palavra sobre o motivo. Uma página de redefinição de senha que
mostra o formulário mesmo assim falha ao salvar com "Auth session missing!",
porque o link não fez o login de ninguém, e
essa mensagem tem um artigo próprio.
Pense no link como um ingresso que deixa uma pessoa entrar uma única vez. Quem o entrega primeiro passa, e o ingresso é furado. "Email link is invalid or has expired" é a pessoa na porta segurando um ingresso que já tem um furo, ou um cujo horário já passou. Se os seus usuários entram e mais tarde são desconectados, é outro erro, com uma página própria.
Por que um link enviado há um minuto já foi usado
Porque alguma coisa o abriu antes do seu usuário, e abrir um link de e-mail do Supabase é o mesmo que usá-lo.
O guia de modelos de e-mail do Supabase coloca isso entre as limitações: alguns provedores de e-mail têm detecção de spam ou outros recursos de segurança que abrem antecipadamente os links dos e-mails recebidos. O exemplo que ele dá é o Safe Links do Microsoft Defender for Office 365. A página do Supabase para resolver este erro chama essa abertura antecipada de motivo mais comum para um token parecer expirado ou inválido antes mesmo de o usuário conseguir usá-lo.
O link padrão não tem nenhum passo entre abrir e usar. O endereço dele é o endpoint de verificação do seu projeto, que confere o token e o gasta na primeira visita, seja de quem for. Então o filtro entrega o ingresso enquanto a mensagem chega, e ele passa. O seu usuário clica um minuto depois e é barrado na porta.
Daí saem duas coisas.
- Você não vai ver isso acontecer. Acontece com pessoas cujo e-mail passa por um filtro desse tipo, e a sua própria caixa pode nunca ser uma delas. Os seus cadastros de teste continuam funcionando enquanto os seus usuários escrevem para você.
- A conta normalmente já está confirmada. Numa confirmação de cadastro, a visita do filtro conta: o Supabase confirma o endereço de quem abrir o link. Se um filtro o abriu, a conta está confirmada quando o seu usuário vê o erro, e ele pode entrar com a senha que escolheu.
As outras três causas
Cada uma produz a mesma mensagem, e cada uma pede uma coisa diferente de você.
O link já foi usado. O seu usuário o apertou uma vez, talvez no celular, e agora aperta o mesmo e-mail no notebook. Quando um link funciona, o Supabase apaga todos os links que ainda estavam pendentes para aquela conta. O código de contas dele faz a mesma limpeza depois de uma confirmação e depois de um link de login, então um segundo clique não encontra nada. O ingresso foi furado na primeira visita.
Um e-mail mais recente o substituiu. Apertar "reenviar" cria um link novo, e o Supabase apaga o anterior do mesmo tipo antes de salvá-lo. Isso acontece no código que cria o token. O primeiro e-mail continua na caixa parecendo normal, e o link dele falha. Só o e-mail mais recente funciona. O Supabase também só deixa um endereço pedir esse tipo de e-mail uma vez a cada 60 segundos, o que o limite de e-mails explica por completo.
Expirou de verdade. O guia de login sem senha do Supabase diz que os magic links expiram por padrão depois de 1 hora. A mesma página diz que a configuração Email OTP Expiration também rege a validade dos magic links e dos outros links de e-mail, incluindo os de confirmação, recuperação de senha, troca de e-mail e convite. Quem se cadastra à noite e abre o e-mail na manhã seguinte encontra esta mensagem, faça o que fizer o seu serviço de e-mail.
O que acontece quando o link é aberto em outro aparelho?
Se o seu app usa o fluxo PKCE, o outro aparelho falha primeiro com uma mensagem diferente, e esta vem depois.
O PKCE divide o ingresso em dois. O guia de PKCE do Supabase explica que o code verifier é criado e guardado localmente quando o fluxo do Auth começa, e que por isso a troca do código precisa ser iniciada no mesmo navegador e no mesmo aparelho onde o fluxo começou. O e-mail leva uma metade. O navegador em que o seu usuário pediu o link fica com o canhoto.
Aberto num celular, o link passa mesmo assim pela verificação do Supabase, que o gasta. Depois o seu app procura o canhoto no celular e não encontra nenhum. O cliente JavaScript do Supabase avisa isso com as próprias palavras:
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.
Aí a pessoa volta ao notebook e aperta o mesmo e-mail lá, e o Supabase responde
"Email link is invalid or has expired", porque o celular já fez o ingresso ser
furado. Se o endereço em que o seu app abre depois de um link que funciona
contém ?code=, o seu app usa PKCE e este caso vale para você.
Como descubro qual delas foi?
Pergunte ao seu próprio banco de dados. Cada conta do seu projeto é uma linha
numa tabela chamada auth.users, e essa linha registra quando cada tipo de
e-mail foi enviado e quando o endereço foi confirmado.
Abra o SQL Editor no painel do Supabase e rode isto com o endereço que a pessoa passou:
select email,
confirmation_sent_at,
email_confirmed_at,
recovery_sent_at
from auth.users
where lower(email) = lower('person@example.com');
A consulta lê uma linha e não muda nada. confirmation_sent_at é quando saiu a
última confirmação de cadastro. recovery_sent_at é quando saiu o último magic
link ou a última redefinição de senha para uma conta que já existia. São os
momentos a partir dos quais o Supabase mede a hora, na mesma função que devolve
o erro. Compare com o horário do e-mail que a pessoa apertou.
| O que você vê | O que significa |
|---|---|
Um link de confirmação falhou no primeiro clique, e email_confirmed_at está preenchido | Alguma coisa abriu o link antes da pessoa. A conta está confirmada, então ela pode entrar com a senha dela |
Um link de confirmação falhou, email_confirmed_at está vazio, e ela apertou mais de uma hora depois do envio | Expirou. Envie uma confirmação nova |
confirmation_sent_at ou recovery_sent_at é posterior ao e-mail que ela apertou | Um e-mail mais recente o substituiu. Só o link do mais novo funciona |
| Um magic link do e-mail mais recente falhou no primeiro clique, dentro da hora | Alguma coisa o abriu primeiro: um filtro de e-mail, ou a própria pessoa em outro aparelho |
Se a linha mostra o endereço confirmado e a pessoa ainda não consegue entrar com senha, Invalid login credentials é a próxima mensagem para ler.
O que não mudar enquanto você corrige
A validade, a chave Confirm email e quem pode confirmar um usuário. Cada um dos atalhos óbvios faz a mensagem parar e abre alguma coisa.
Deixe a validade em uma hora. Um filtro abre o link enquanto a mensagem chega, então um link que dura uma semana também é usado primeiro por ele. O guia de login sem senha do Supabase desaconselha fortemente mais de 86.400 segundos, ou seja, um dia. Um link de login faz o login de quem o abrir. Quanto mais tempo ele funciona, mais tempo um e-mail encaminhado, uma caixa compartilhada ou uma mensagem antiga no arquivo de alguém são um caminho para dentro daquela conta.
Deixe Confirm email ligado. Se você desligar, os cadastros deixam de enviar qualquer link e este erro para. Também significa que qualquer pessoa pode criar uma conta com um endereço que nunca controlou.
Não confirme usuários pelo navegador. Peça a um assistente para "só marcar
como confirmados" e ele pode recorrer às funções de administração do Supabase,
que conseguem definir email_confirm numa conta. A
referência de administração
do Supabase diz que qualquer método em supabase.auth.admin exige uma chave
secret e que essa chave nunca deve ser exposta no navegador. Uma chave secreta
no seu frontend lê e grava todas as tabelas do seu banco, digam o que disserem
as suas regras, para qualquer pessoa que abra o código do seu site. É a chave
que este artigo sobre chaves de API
manda você rotacionar. Se você não tem certeza de que nenhuma escapou para lá, a
nossa varredura gratuita lê o código que o seu app no ar envia a cada visitante
e aponta as chaves secretas que encontra ali. Leva cerca de 20 segundos e não
pede conta: escaneie o seu app.
Confira qualquer endereço que a sua página de confirmação repassa. A segunda solução sugerida pelo Supabase é uma página sua com um botão, alcançada por um link neste formato:
<a href="{{ .SiteURL }}/confirm-signup?confirmation_url={{ .ConfirmationURL }}">
O botão leva então para onde confirmation_url disser. Feita sem conferência,
essa página manda qualquer pessoa que siga um link adulterado para qualquer
endereço, com o nome do seu app. Ou você confere se o endereço começa com o do
seu próprio projeto, https://<project-ref>.supabase.co/auth/v1/verify, ou usa
a versão com o token da próxima seção, que não faz nenhum endereço passar pela
página.
Como enviar um link que um filtro de e-mail não consiga gastar
Faça com que abrir o endereço não faça nada, de modo que só entre uma pessoa que digita um código ou aperta um botão. O Supabase documenta as duas opções, e você as muda nos modelos de e-mail, em Authentication no seu painel. Confirm signup e Magic Link são os dois para editar.
Envie um código. Coloque {{ .Token }} no modelo. O guia do Supabase o
descreve como uma senha de uso único de 6 dígitos que pode ser usada no lugar
do link. A sua página pede o endereço de e-mail e o código, e então chama:
const { error } = await supabase.auth.verifyOtp({ email, token, type: 'email' })
Um filtro consegue abrir um e-mail. Não consegue digitar um código na sua página.
Faça um link para uma página com um botão. Aponte o link para uma página sua e leve o hash do token, para que nenhum endereço precise passar:
<a href="{{ .SiteURL }}/confirm?token_hash={{ .TokenHash }}&type=email">Confirm your email</a>
{{ .SiteURL }} é a Site URL da página URL Configuration do seu projeto. Se
ela ainda diz http://localhost:3000,
corrija isso primeiro.
A página mostra um botão, e só o botão faz o trabalho:
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()
}
O tipo email serve para os dois modelos: a verificação do Supabase procura o
hash tanto entre as confirmações de cadastro quanto entre os links de login.
A página não pode chamar verifyOtp ao carregar. O
guia de senhas do Supabase
monta o link de confirmação dele como
{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}
e o verifica no momento em que esse endereço é aberto. Isso é de novo o link
padrão num endereço novo, e um filtro o abre com a mesma facilidade.
O que quer que você mude, algumas pessoas ainda vão dar com um link antigo, então dê a elas uma saída. A página onde os seus links chegam pode ler o erro e oferecer um e-mail novo:
const hash = new URLSearchParams(window.location.hash.slice(1))
if (hash.get('error_code') === 'otp_expired') showSendNewLink()
Num cadastro, acrescente uma linha dizendo que a pessoa talvez já esteja
confirmada e pode tentar entrar. Se um builder escreve as suas páginas de
login, peça a versão com código ou com botão dos dois modelos, uma página de
chegada que leia otp_expired e ofereça um link novo, e nenhuma chave de
administração em lugar nenhum do app.
Saber dos problemas antes que os seus usuários escrevam
Este erro chega aos seus usuários e nunca a você, e outras falhas também chegam primeiro a eles: o app que cai de madrugada, ou um prompt posterior que coloca uma chave no código do seu app.
Reeve Monitor continua verificando o seu app no ar de fora:
- se ele está no ar, a cada 60 segundos, para separar "o link não funciona" de "o site caiu"
- as nove verificações a cada hora, em até três apps, incluindo chaves secretas no código que o seu app envia aos visitantes
- uma mensagem quando um resultado muda, para que uma chave que sai num deploy posterior chegue até você sem você precisar ir olhar
- um relatório mensal, em linguagem simples, do que ele viu
Reeve Care guarda uma cópia do seu banco de dados Supabase, contas incluídas:
- uma cópia criptografada em uma programação, no armazenamento próprio da Reeve na UE, fora da sua conta do Supabase
- o schema auth onde vivem as contas dos seus usuários, para que elas voltem com todo o resto
- cada cópia verificada antes de contar, contando as linhas de cada tabela
- seus arquivos enviados também, assim que você conectar uma credencial do Storage
- uma restauração com um clique, com uma cópia de segurança do estado atual feita antes
- tudo o que o Monitor faz
O que fazer agora
O que fazer
- Leia a linha da pessoa em
auth.users. Umemail_confirmed_atpreenchido numa confirmação que falhou significa que um filtro abriu o link e que ela pode entrar. - Diga às pessoas para usar o e-mail mais recente e, se o seu app usa PKCE, para abri-lo no aparelho em que o pediram.
- Mude os modelos Confirm signup e Magic Link para enviar um código, ou para abrir uma página onde um botão chama
verifyOtp. - Faça a página onde os seus links chegam ler
error_code=otp_expirede oferecer um link novo. - Deixe a validade em uma hora, deixe Confirm email ligado e mantenha a chave secreta fora do navegador.
Se os e-mails nem chegam, o limite de e-mails é a primeira coisa a descartar. O checklist de segurança de 10 minutos cobre o que costuma ficar aberto num app recém-lançado.
Perguntas frequentes
Por que um magic link do Supabase diz que expirou logo depois de ser enviado?
Normalmente porque alguma coisa o abriu antes da pessoa. Alguns serviços de e-mail abrem os links de uma mensagem recebida para verificá-los, e o Supabase cita o Safe Links do Microsoft Defender for Office 365 como exemplo. Abrir um link de e-mail do Supabase é usá-lo, então quando a pessoa clica um minuto depois o token já não existe e o Supabase responde "Email link is invalid or has expired".
A conta está confirmada mesmo o link dizendo que expirou?
Se um filtro de e-mail abriu primeiro um link de confirmação de cadastro, sim. O Supabase confirma o endereço para quem abrir o link, então a visita do filtro o confirmou. Veja email_confirmed_at em auth.users: se estiver preenchido, a pessoa pode entrar com a senha que escolheu.
Quanto tempo vale um link de e-mail do Supabase?
Uma hora por padrão. Quem controla é a configuração Email OTP Expiration, e essa mesma configuração vale para magic links, confirmações de cadastro, redefinições de senha, trocas de e-mail e convites. O Supabase desaconselha fortemente qualquer valor acima de 86.400 segundos, ou seja, um dia.
Devo fazer os links durarem mais?
Não. Um filtro de e-mail abre o link quando a mensagem chega, então uma validade maior não o impede de usá-lo primeiro. Ela só mantém funcionando por mais tempo cada link não usado em cada caixa de entrada, e um link faz o login de quem o abrir, inclusive de alguém para quem o e-mail foi encaminhado.
Por que o link funciona no meu computador e falha no meu celular?
Se o seu app usa o fluxo PKCE, o navegador em que a pessoa pediu o link guarda metade do que o login precisa, então abrir o e-mail em outro aparelho falha com "PKCE code verifier not found in storage". Essa tentativa também gasta o link, e é por isso que clicar de novo no primeiro aparelho depois diz "Email link is invalid or has expired".
O que significa error_code=otp_expired no endereço do meu app?
É a forma como o Supabase Auth avisa que um link de e-mail falhou. Ele manda a pessoa para o seu app com error=access_denied, error_code=otp_expired e a mensagem na parte do endereço que vem depois do sinal #. O seu app pode ler error_code ali e mostrar uma página que ofereça enviar um link novo.