Pular para o conteúdo

Noções de segurança

"Database error saving new user" no Supabase: onde olhar

"Database error saving new user" quer dizer que algo preso ao seu cadastro no Supabase falhou e tudo foi desfeito. O erro real está nos seus logs.

Vlad Tkachenko12 min de leitura
Um livro de registro sobre um balcão com a linha mais recente já riscada, e atrás dele um segundo livro numa sala com a porta trancada.

Em resumo

  • "Database error saving new user" quer dizer que o Supabase aceitou o cadastro, algo preso a ele falhou e tudo foi desfeito. Nenhuma conta foi criada.
  • A mensagem é vaga de propósito, e a de verdade já está anotada. A própria página de solução do Supabase manda você primeiro nos logs do Auth e depois nos do Postgres, na linha que fica embaixo.
  • Quase sempre é um trigger em auth.users que não alcança a tabela em que escreve. A saída é uma função security definer, e o que precisa ficar certo é quanto ela tem permissão de fazer.

Alguém tenta criar uma conta no seu app e recebe de volta uma resposta que não é sobre ele:

Database error saving new user

Ninguém consegue se cadastrar. O formulário funcionava quando você construiu, o seu projeto está no ar, e a única coisa que você tem é uma frase que não nomeia tabela, nem coluna, nem motivo.

Aqui está a parte que um guia atrás do outro erra: essa mensagem foi feita para não te dizer nada, e o seu erro real já está escrito em outro lugar. Procure a frase e te entregam um bloco de SQL para colar, normalmente a versão de alguém de uma função que escreve numa tabela com os nomes de coluna dessa pessoa. Colar ou funciona, ou soma um segundo problema ao primeiro. A linha que diz o que falhou está nos logs do seu próprio projeto, e ela nomeia a tabela.

O que quer dizer "Database error saving new user"

O Supabase aceitou a conta nova e depois desfez, porque algo preso ao cadastro falhou.

Um cadastro escreve uma linha, numa tabela chamada auth.users que o Supabase administra para você. A maioria dos projetos prende alguma coisa a essa escrita: um trigger que copia a pessoa nova para uma tabela profiles sua, uma constraint, uma chave estrangeira. Quando essa coisa presa levanta um erro, o banco desfaz a operação inteira, e isso inclui a linha em auth.users. Não sobra nenhuma conta pela metade. E também não tem nada na resposta dizendo qual das coisas presas foi.

Pense num balcão com uma sala nos fundos. Alguém assina o livro no balcão, e um funcionário leva o nome novo para trás para copiar num segundo livro. Se o funcionário não consegue entrar na sala onde esse livro fica, a inscrição não vale: a linha do balcão é riscada e dizem à pessoa que houve um problema. O que ninguém conta a ela é que o problema era uma porta trancada doze metros adiante.

A vagueza é de propósito, e é a decisão certa. O seu formulário de cadastro está aberto na internet. Uma mensagem que nomeasse as suas tabelas estaria descrevendo o seu banco para qualquer um disposto a digitar um e-mail nele.

Se a resposta disser email rate limit exceeded, o problema não é o seu banco: o Supabase se recusou a enviar o e-mail de confirmação, e esse erro tem uma página própria.

A linha em auth.users é escrita e tirada de volta, então da falha não sobra nada além de uma linha num log que a resposta nunca menciona.

Onde está o erro real?

Nos logs do seu projeto, em dois passos, e a própria página de solução do Supabase dá a ordem.

Reproduza uma vez antes de sair procurando, para ter uma hora com que casar. Tente um cadastro no seu app em produção, deixe falhar e anote o momento.

Depois, no painel do Supabase:

  1. Abra Logs e leia os logs do Auth. Ache a entrada naquela hora. Ela carrega o erro que o Auth anotou por conta própria, mais específico do que a frase que o seu app recebeu.
  2. Se essa entrada apontar para o banco, volte em Logs e abra os do Postgres. A linha do mesmo momento é o seu erro real, escrita no vocabulário comum do Postgres: uma coluna, uma relação, um tipo, uma permissão.

A nota de solução do Supabase para essa mensagem lista três causas. Você tem um trigger ou uma função de trigger na tabela auth.users com um erro. Você adicionou em auth.users uma constraint que não está sendo atendida. Ou você está usando Prisma e ele quebrou as permissões em auth.users. É dessa mesma nota que vem o caminho de dois passos pelos logs acima.

Num app feito com Lovable, Bolt, Replit ou v0 a primeira causa cobre a maioria, porque copiar um cadastro novo para uma tabela profiles é o padrão a que todo builder recorre, e um trigger é como isso é feito.

Causa 1: o trigger não alcança a sua tabela

O papel de banco que executa o seu trigger não pode sair do schema auth, e o seu trigger escreve fora dele.

O Supabase explica isso no próprio fórum de discussão, e vale ter a formulação exata. O papel de banco que administra a autenticação, supabase_auth_admin, só tem as permissões de que precisa para as próprias tarefas, então quando um trigger operado por supabase_auth_admin interage fora do schema auth, isso provoca um erro de permissão.

Esse é o crachá do funcionário. Abre o escritório da frente, porque é ali que está o trabalho dele. O segundo livro fica numa sala para a qual esse crachá nunca foi feito, e uma porta trancada não informa por que está trancada.

No log do Postgres isso chega como um erro de permissão que nomeia a tabela em que o seu trigger estava escrevendo.

A saída, e o que ela entrega

security definer na função, que a faz rodar com os privilégios de quem a criou em vez dos de quem a chamou.

O Supabase diz numa linha: uma função security definer mantém os privilégios do usuário de banco que a criou, e enquanto esse for o papel postgres, os seus triggers de auth deveriam conseguir interagir com tabelas de fora. O funcionário deixa de levar o próprio crachá e leva o do gerente.

Isso é uma transferência real de autoridade, então vale saber o que você escreveu antes de colar. Cada instrução dentro dessa função agora roda como o dono. O próprio exemplo do Supabase deixa o search_path vazio, com security definer set search_path = '', e é por isso que o insert ali nomeia public.profiles inteiro em vez de profiles. Com o search_path vazio um nome de tabela sozinho não resolve mais para nada. Numa função que carrega os privilégios do dono ninguém consegue então trocar o alvo em silêncio por outra tabela, conforme o que o search_path de quem chama estiver dizendo.

Qual crachá a função carrega é toda a diferença. security definer é a faixa de baixo, e é o crachá do dono.

O que isso não autoriza é recorrer a security definer na próxima vez que algo for negado. Deixe a função fazendo uma coisa e retornando, que é o que a versão do Supabase faz.

Causa 2: uma regra na tabela em que você escreve

Row Level Security na sua tabela profiles pode recusar o insert, e o cadastro falha então exatamente do mesmo jeito.

O guia do Supabase para esse padrão manda ligar Row Level Security nessa tabela, e essa instrução está certa. Para o seu trigger quer dizer que o insert precisa estar permitido, ou por uma policy que o cubra, ou por uma função que rode como alguém a quem as policies não se aplicam.

É aqui que isso deixa de ser um bug e vira uma decisão que alguém toma às onze da noite. Ninguém consegue se cadastrar, o erro não diz nada, e no painel tem um interruptor, escrito em linguagem clara, que faz o erro sumir: desligar Row Level Security em profiles. A tentativa seguinte funciona. O que também acontece é que uma tabela com os seus usuários, com tudo o que você guarda nela ao lado dos nomes deles, passa a responder a qualquer um que pedir.

Essa parte dá para ver de fora, e é assim que conseguimos contar. O nosso scanner lê apps em produção como um estranho leria. Encontramos um projeto Supabase atrás de 8.435 dos apps que ele avaliou. Em 3.680 deles a pergunta "alguém sem conta consegue ler esta tabela" teve uma resposta utilizável, e 2.096 responderam que sim. Em 394 deles a tabela aberta tinha nome de tabela de pessoas.

Parte disso é de propósito, porque um cardápio publicado e uma página de anúncios ficam em tabelas que um estranho deve ler, e o nosso scanner não consegue distinguir essas de uma tabela de clientes que alguém abriu para se livrar de um erro. Ele relata o que respondeu. Se você prefere ver a sua própria resposta a ficar raciocinando sobre ela, a nossa varredura gratuita lê o seu app em produção e diz quais tabelas responderam. Leva uns 20 segundos e não precisa de conta: escaneie o seu app.

A policy de que um trigger precisa é bem mais estreita do que desligar as regras. Ligar Row Level Security em toda tabela explica como escrever uma, e "new row violates row-level security policy" é a mensagem que você recebe quando uma policy recusa um insert que você está vendo com os próprios olhos.

As outras causas, e como cada uma aparece no log

Mais quatro, e a linha do log distingue todas em poucas palavras.

Uma tabela que não está lá. A nota do Supabase cobre isso: uma função de trigger que aponta para uma tabela que não existe ou foi apagada. Uma tabela profiles apagada e reconstruída com outro nome leva junto todo cadastro, e o log nomeia a relação que falta.

Uma constraint em auth.users. Um check ou uma chave estrangeira adicionada nessa tabela que a linha nova não satisfaz. Vale procurar mesmo que você tenha certeza de não ter criado trigger nenhum, porque uma chave estrangeira entre uma tabela sua e o usuário do auth é o jeito mais comum de essa causa chegar. No comportamento padrão, essa mesma chave estrangeira é a que recusa a exclusão quando essa pessoa é removida depois, e essa mensagem tem um post só dela.

Um valor que não é do tipo certo. Essa aparece palavra por palavra. Um trigger que copia campos do cadastro de dentro de raw_user_meta_data lê com ->>, e ->> devolve texto independentemente da cara que o valor tivesse ao entrar. Escrever esse texto numa coluna date produziu, num caso relatado, column "birth_date" is of type date but expression is of type text. O conserto é um cast, com a extração entre parênteses primeiro:

(new.raw_user_meta_data->>'birthDate')::date,
(new.raw_user_meta_data->>'height')::integer,

Linhas inseridas na mão. A nota do Supabase cobre também as contas criadas direto por SQL em vez de pela API do Auth, em que colunas obrigatórias do schema auth ficam como NULL em vez de strings vazias. Se alguém semeou usuários com um insert, esse é um conserto diferente de todos os de cima.

Mais uma linha que você pode encontrar ali: duplicate key value violates unique constraint. Ela quer dizer que a linha que o seu trigger grava já estava lá, por exemplo porque seu app insere o mesmo perfil depois do cadastro, e esse erro tem um post só dele.

E mais uma: null value in column … violates not-null constraint. Seu trigger gravou um valor vazio numa coluna que exige um, quase sempre um campo de cadastro que o formulário nunca mandou, e esse também tem um post só dele.

A última que você pode encontrar é insert or update on table "profiles" violates foreign key constraint. Quase sempre significa que o trigger foi criado com before insert, então a pessoa para quem ele grava um perfil ainda não foi salva. O padrão do Supabase mais abaixo usa after insert, e essa mensagem tem um post só dela.

A versão que funciona, inteira

Comece pelo padrão que o Supabase publica e não por um resultado de busca, porque quatro coisas ali sustentam o peso e uma paráfrase costuma perder uma delas.

create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.profiles (id, first_name, last_name)
  values (new.id, new.raw_user_meta_data ->> 'first_name', new.raw_user_meta_data ->> 'last_name');
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

As quatro são security definer, o search_path vazio, o public.profiles escrito inteiro e o return new que deixa o cadastro seguir. Os seus próprios nomes de coluna entram no insert, e toda coluna que você nomear ali precisa existir e precisa aceitar o que ->> entrega.

O guia do Supabase acrescenta a frase que vale repetir: se o trigger falhar, ele pode bloquear os cadastros, então teste o seu código a fundo. Crie uma conta de verdade no seu app em produção depois de cada mudança nele.

Como manter o seu cadastro funcionando depois de mexer

Um cadastro é a parte do seu app que você para de testar no dia em que funciona. Você se cadastrou uma vez, tem conta, e daí em diante você faz login. O próximo prompt que mexer nas suas tabelas pode quebrar o cadastro para todo mundo enquanto a sua própria sessão continua se comportando normalmente.

O Reeve Monitor olha por você:

  • as nove verificações a cada hora, em até três apps
  • um e-mail no dia em que uma nova varredura avaliar o seu app pior que a anterior
  • se o app está no ar, a cada 60 segundos
  • um relatório mensal do que ele viu

Uma regra desligada para se livrar de um erro deixa legível uma tabela de pessoas. O Reeve Care guarda uma cópia do seu banco Supabase onde nenhuma mudança nas suas regras alcança.

  • uma cópia criptografada todo dia, guardada fora da sua conta Supabase
  • cada cópia verificada antes de contar, contando as linhas de cada tabela
  • uma restauração em um clique que salva o que está lá agora antes de substituir
  • também os seus arquivos enviados, assim que você conectar uma credencial de Storage
  • tudo o que o Monitor faz

O que fazer hoje

O que fazer

  • Reproduza um cadastro que falhou e anote a hora antes de mudar qualquer coisa.
  • Leia a entrada do log do Auth naquele momento, e depois a linha do log do Postgres na mesma hora se a entrada do Auth apontar para o banco. Essa linha nomeia a coluna, a relação ou a permissão que falhou.
  • Confira se a sua função de trigger carrega security definer e nomeia o alvo inteiro, como public.profiles em vez de profiles.
  • Se uma policy nessa tabela está recusando o insert, escreva uma policy para ele. Desligar Row Level Security tira o erro e deixa essa tabela legível para qualquer um que pedir.
  • Crie uma conta de verdade no seu app em produção depois de cada mudança num trigger de cadastro. A sua própria sessão esconde completamente um cadastro quebrado de você.

Comece pela tabela em que o seu trigger escreve, e depois leia as policies de toda tabela em que você mexeu na mesma semana. A lista de segurança de 10 minutos cobre o que mais costuma ficar aberto num app recém-lançado, e o guia de segurança do Supabase passa pelo resto do que um estranho consegue alcançar.

Perguntas frequentes

O que quer dizer "Database error saving new user"?

Quer dizer que o Supabase escreveu a pessoa nova na própria tabela auth.users, que algo preso a essa escrita falhou e que o banco desfez a operação inteira, incluindo a linha nova. Então nenhuma conta foi criada e não sobrou nada pela metade para limpar. A mensagem não nomeia tabela nem motivo de propósito, porque o seu formulário de cadastro está aberto na internet e um erro específico descreveria o seu banco para qualquer um que digitasse um e-mail nele.

Onde eu acho o erro real?

Nos logs do seu projeto, e a própria página de solução do Supabase dá a ordem. Tente um cadastro, deixe falhar e anote a hora. Abra Logs e leia a entrada do Auth daquele momento, que carrega o que o Auth anotou por conta própria. Se essa entrada apontar para o banco, abra os logs do Postgres na mesma hora: essa linha nomeia a coluna, a relação ou a permissão que de fato falhou.

Row Level Security na minha tabela profiles quebra o cadastro?

Pode quebrar. Se o seu trigger insere numa tabela com Row Level Security e nenhuma policy permite esse insert, o insert é recusado e o cadastro falha com a mesma mensagem vaga. A saída é uma policy para ele, ou uma função que roda com privilégios aos quais as policies não se aplicam. Desligar as regras nessa tabela também tira o erro, e deixa uma tabela com os seus usuários legível para qualquer um que pedir.

O que é security definer e é seguro?

Faz a função rodar com os privilégios de quem a criou em vez dos de quem a chama, que é justamente o que permite a um trigger executado pelo papel do auth escrever numa tabela sua. É o padrão que o Supabase publica, e é uma transferência real de autoridade, então a função deveria fazer uma coisa só e bem delimitada. O Supabase ainda deixa o search_path vazio no exemplo dele, e é por isso que ali o insert nomeia public.profiles inteiro e não só profiles.

Eu nunca criei trigger nenhum. O que mais pode ser?

Outras três coisas. Uma constraint ou uma chave estrangeira adicionada em auth.users que a linha nova não satisfaz. Uma função de trigger que aponta para uma tabela renomeada ou apagada. E a nota do Supabase cita o Prisma pelo nome, porque ele pode quebrar as permissões em auth.users. Qual delas é, a linha do log do Postgres te diz.

Escrito por

Vlad Tkachenko

Fundador da Reeve

Passo meus dias olhando apps criados com Lovable, Bolt, v0, Cursor e Replit, e a curta lista de erros que aparecem neles sem parar.

Mais sobre o autor

Leia depois

Todos os artigos

Faça backup do seu banco Supabase antes da próxima mudança

O Reeve guarda uma cópia criptografada em horários programados e a restaura para você se algo quebrar.

Ver backups do Supabase

Verificação externa automatizada, não uma auditoria completa. A ausência de achados não é garantia de segurança.