Pular para o conteúdo

Noções de segurança

Erro do Supabase "violates foreign key constraint" ao inserir

Supabase "insert or update on table violates foreign key constraint": sua linha aponta para uma linha que não existe. Qual, por quê, e como corrigir.

Vlad Tkachenko13 min de leitura
Uma rua de casas numeradas, com um terreno vazio contornado em âmbar, e na frente uma carta endereçada ao número que falta.

Em resumo

  • Supabase "violates foreign key constraint" ao inserir (código do Postgres 23503, HTTP 409 no seu app) significa que a linha que você salvou cita uma linha de outra tabela que não existe. Nada foi salvo. A mensagem cita a sua tabela e a regra, e a linha de detalhe cita a tabela onde o Postgres procurou.
  • A linha que falta nunca foi salva, foi salva em segundo lugar, foi apagada nesse meio-tempo ou mora em outro projeto. Quando a linha de detalhe diz "users", quase sempre se trata de auth.users, onde o Supabase Auth guarda todas as pessoas cadastradas.
  • A verificação lê cada linha da outra tabela, não importa o que digam suas regras de Row Level Security. Então ela também aceita uma linha que aponta para o registro de outra pessoa, e sua regra de inserção precisa conferir de quem é esse registro.

Você clica em Salvar num perfil novo no seu app e nada muda. A página não mostra nenhuma mensagem. O console do navegador tem uma:

{
  "code": "23503",
  "details": "Key is not present in table \"users\".",
  "hint": null,
  "message": "insert or update on table \"profiles\" violates foreign key constraint \"profiles_id_fkey\""
}

Supabase "violates foreign key constraint" significa que a linha que você tentou salvar cita uma linha de outra tabela, e essa linha não existe. Aqui o perfil levava um id que deveria pertencer a alguém que se cadastrou, e o Postgres não encontrou ninguém com esse id. Nada foi salvo.

O jeito mais rápido de a mensagem sumir é apagar a regra. Depois disso os salvamentos passam, e as linhas que eles criam apontam para o nada: um item de pedido sem pedido, um perfil sem ninguém por trás. Descubra primeiro qual linha falta e por quê. Ela sumiu de uma de poucas maneiras.

O que significa "violates foreign key constraint" no Supabase?

Significa que uma coluna da sua linha precisa bater com uma linha de outra tabela, o valor que você enviou não bate com nenhuma, e o Postgres recusou a linha inteira. A documentação do Postgres sobre restrições define a regra numa frase: uma chave estrangeira "specifies that the values in a column (or a group of columns) must match the values appearing in some row of another table."

Pense na outra tabela como uma rua, e em cada linha dela como uma casa com um número. Sua linha nova é uma carta, e a coluna com a chave estrangeira é o número da casa escrito nela. O Postgres é o centro de triagem. Antes de aceitar a carta, ele procura o número na rua, e uma carta para uma casa que não existe volta na hora. A mensagem diz qual carta voltou. A linha de detalhe diz em qual rua se procurou.

CampoExemploO que ele diz
code23503O código do Postgres para este erro, chamado foreign_key_violation na lista de códigos dele
messageinsert or update on table "profiles" violates foreign key constraint "profiles_id_fkey"A tabela em que você escreveu, e depois o nome da regra
detailsKey (id)=(…) is not present in table "users"., ou o mesmo sem o valorA coluna, o valor que você enviou e a tabela onde o Postgres procurou. A próxima seção explica quando o valor fica de fora
hintnullNada a acrescentar para este erro
Status HTTP409 ConflictO PostgREST, a parte do Supabase que transforma chamadas supabase.from(...) em SQL, associa 23503 ao status 409 no código dele

O texto vem do arquivo do Postgres que roda essas verificações. Um update também o provoca, quando troca a coluna por um número sem casa, e por isso a mensagem começa com "insert or update".

Uma mensagem que começa com update or delete on table é a mesma regra vista do lado da rua: você tentou derrubar uma casa para a qual cartas ainda apontam. Essa vem de apagar, e o post sobre apagar usuários trata dela.

Qual linha o Postgres está procurando?

A da linha de detalhe, na tabela que essa linha cita. Duas coisas a deixam mais difícil de ler do que parece.

Às vezes o valor fica de fora. O Postgres mostra Key (id)=(…) só quando quem chama pode ler essa coluna da sua tabela. Quando o Row Level Security está ligado na tabela em que você escreveu, o código dele omite o valor, com o comentário "we don't return any specifics to avoid leaking data", e você recebe Key is not present in table "users". É isso que vê uma requisição do navegador para uma tabela protegida. O valor é o que seu app enviou, então abra as ferramentas de desenvolvedor do navegador, encontre a requisição para /rest/v1/profiles na aba Rede e leia o valor no payload.

Se o seu console mostra o valor numa requisição que seu app fez com a chave pública, o Row Level Security está desligado na tabela em que você escreveu. Uma tabela com as regras desligadas entrega as linhas a um estranho tão facilmente quanto a você, e ligar as regras em todas as tabelas mostra o interruptor e a primeira regra. Nossa varredura gratuita lê seu app no ar com a chave pública que qualquer visitante recebe e lista as tabelas que respondem a ela. Não precisa de conta.

O nome da tabela vem sem schema. O Postgres mostra users, e num projeto Supabase uma coluna que aponta para pessoas quase sempre aponta para auth.users, a tabela onde o Supabase Auth guarda todas as pessoas cadastradas. Ela não aparece entre suas tabelas públicas no Table Editor, e por isso a mensagem parece citar uma tabela que você nunca criou.

Para ver exatamente para onde a regra aponta, rode isto no SQL Editor com o nome da regra que vem na mensagem:

select conrelid::regclass as on_table,
       pg_get_constraintdef(oid) as rule
from pg_constraint
where conname = 'profiles_id_fkey';

A resposta traz a tabela que tem a regra e o que a regra diz, por exemplo FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE: sua coluna, e depois a tabela e a coluna com que ela precisa bater. Depois procure o valor:

select id, email, created_at from auth.users where id = '…';

Se nenhuma linha voltar, a pessoa que seu perfil cita não existe neste projeto.

De onde vem uma linha que aponta para o nada?

De poucos lugares, e a tabela da linha de detalhe diz onde olhar primeiro.

A linha para a qual ela aponta nunca foi salva, ou foi salva em segundo lugar. Um pedido e seus itens são dois salvamentos. Se seu app salva primeiro os itens, ou salva o pedido, recebe um erro que nunca lê e segue com os itens, cada item cita um pedido que não existe. Salve primeiro a linha para a qual se aponta, pegue o id dela na resposta e leia o erro de cada passo antes do próximo:

const { data: order, error } = await supabase
  .from('orders')
  .insert({ user_id: user.id })
  .select('id')
  .single()

if (error) throw error

const { error: linesError } = await supabase
  .from('order_items')
  .insert(cart.map((item) => ({ ...item, order_id: order.id })))

.select() depois de um insert lê a linha nova de volta, então a pessoa precisa de uma regra que a deixe ler os próprios pedidos. A documentação do Postgres sobre policies diz que uma linha nova que não passa nas regras de leitura faz o salvamento lançar um erro, e no Supabase esse erro é new row violates row-level security policy. Os dois salvamentos são requisições separadas, então se os itens falharem o pedido já está salvo: envie os itens de novo ou apague o pedido.

Itens salvos antes do pedido apontam para uma casa que ainda não existe. Salve o pedido, pegue o id dele na resposta e só então salve os itens.

O id veio de outro lugar. Cada projeto Supabase tem o próprio auth.users. Um id copiado do seu projeto de teste, um que um construtor com IA deixou no código a partir de uma prévia, ou linhas copiadas de um projeto para outro citam todos pessoas que o projeto no ar nunca viu. Quando você copiar dados entre projetos, copie as linhas para as quais se aponta antes das linhas que apontam para elas. As pessoas por trás de uma coluna que aponta para auth.users não se copiam assim: elas se cadastram no projeto novo e recebem ids novos ao fazer isso. Um perfil cujo id precisa ser o id da pessoa também quebra quando o app o preenche com um valor aleatório novo, como crypto.randomUUID(). Esse número não é de casa nenhuma. Use user.id da sessão logada.

A linha foi apagada nesse meio-tempo. Um produto retirado da sua loja enquanto estava no carrinho de alguém, ou uma conta apagada em outra aba. O salvamento seguinte aponta para uma casa que existia quando a página carregou. A correção fica no seu app: verifique error.code === '23503' e diga à pessoa o que mudou, por exemplo "Esse produto não está mais disponível."

Um trigger que roda cedo demais. Um trigger em auth.users que copia cada pessoa nova para profiles precisa rodar depois de a pessoa ser salva. A documentação do Postgres sobre triggers diz que a mudança que dispara um trigger BEFORE por linha "is naturally not visible to SQL commands executed in a row-level BEFORE trigger, because it hasn't happened yet". Um perfil gravado a partir de um trigger before insert aponta, portanto, para uma pessoa que ainda não existe. Todo cadastro falha, e o Supabase o informa como "Database error saving new user". O padrão do próprio Supabase, que esse post mostra inteiro, usa after insert.

Por que um valor vazio passa?

Porque uma chave estrangeira só confere os valores que estão lá. A documentação do Postgres sobre restrições diz assim: "Normally, a referencing row need not satisfy the foreign key constraint if any of its referencing columns are null." Null é a palavra do Postgres para nenhum valor. Uma carta sem número de casa nunca é procurada. Ela vai para o saco e não pertence a casa nenhuma.

Então um item de pedido salvo com order_id vazio é aceito, e não pertence a pedido nenhum. Seu app o encontra depois, como um item que não aparece no carrinho de ninguém. A mesma página dá a correção: "declare the referencing column(s) as NOT NULL".

alter table public.order_items alter column order_id set not null;

Depois disso um valor vazio também é recusado, com uma mensagem que cita a coluna diretamente, null value in column violates not-null constraint. O comando falha enquanto já existirem linhas com order_id vazio, então liste essas linhas antes com select * from public.order_items where order_id is null.

Uma regra pode estar escondendo a linha procurada?

Não. O Postgres confere uma chave estrangeira contra cada linha da outra tabela, não importa o que digam suas regras de Row Level Security. A documentação dele sobre segurança por linha diz isso com todas as letras: "Referential integrity checks, such as unique or primary key constraints and foreign key references, always bypass row security to ensure that data integrity is maintained." No código que roda a verificação, a busca passa para o dono da outra tabela, com uma flag que impede até uma regra forçada de valer.

Então o centro de triagem trabalha com a planta completa da rua. Seu app vê a rua através das regras dele, que talvez só mostrem as casas que ele pode visitar, e a verificação nunca usa essa visão. Um 23503 significa que a casa não existe para ninguém. Se a linha existe e seu app não consegue vê-la, o salvamento passa.

Seu app lê as linhas que as regras dele permitem. A verificação da chave estrangeira lê todas as linhas, então uma linha que aponta para uma casa que seu app não vê é aceita.

Alguém pode pendurar uma linha num registro que não é seu?

Sim, a menos que sua regra de inserção confira de quem é esse registro. A verificação de que a casa existe ignora suas regras, então ela diz sim para uma casa que é de outra pessoa.

Pegue order_items com esta regra de inserção, que parece segura:

create policy "Add your own order lines"
on public.order_items for insert
to authenticated
with check (auth.uid() = user_id);

Ela confere que o item é seu, e nunca olha a qual pedido ele vai se juntar. Uma pessoa logada que envia o próprio user_id com o order_id de outra pessoa passa pela regra e pela chave estrangeira, e um item que ela escreveu agora está no pedido de um estranho. A mesma forma põe um comentário embaixo do post privado de outra pessoa, ou um membro num time para o qual ninguém o convidou.

Troque por uma regra que olhe o pedido:

create policy "Add lines to your own orders"
on public.order_items for insert
to authenticated
with check (
  exists (
    select 1 from public.orders
    where orders.id = order_items.order_id
      and orders.user_id = auth.uid()
  )
);

Apague a primeira regra quando acrescentar esta. O Postgres combina regras do mesmo tipo com "OR", nas palavras da documentação dele sobre policies, então a antiga continuaria deixando o item passar sozinha.

A verificação também responde a uma pergunta para qualquer um que possa inserir. A frase que vem depois da citada acima, na documentação sobre segurança por linha, avisa: "Care must be taken when developing schemas and row level policies to avoid 'covert channel' leaks of information through such referential integrity checks." Um salvamento que falha com 23503 diz que uma casa não está lá. Um que dá certo diz que está, numa rua que a pessoa não consegue ler. Os ids de usuário do Supabase são valores aleatórios longos que ninguém adivinha. Quando uma coluna aponta para algo adivinhável, como um endereço de e-mail ou um id que conta a partir de 1, uma pessoa pode testar valores um por um e descobrir quais existem.

Você deve remover a chave estrangeira?

Não. Sem ela, o salvamento passa, a linha aponta para o nada, e daí em diante nada avisa quando isso acontece. Um carrinho mostra um item de um produto que você retirou no mês passado, e um perfil fica para trás para uma pessoa que não está mais em auth.users.

A regra também decide o que acontece com essas linhas quando a linha para a qual elas apontam é apagada. Com on delete cascade na regra que vai de profiles para auth.users, apagar uma pessoa apaga o perfil dela. Sem a regra, o perfil fica, com os dados pessoais que tiver. O post sobre apagar usuários mostra como escolher o que cada tabela guarda.

Se linhas antigas já apontam para o nada, liste-as antes de recolocar uma regra ou de apertar uma, porque o Postgres se recusa a criar uma chave estrangeira enquanto alguma linha a violar:

select i.*
from public.order_items i
left join public.orders o on o.id = i.order_id
where i.order_id is not null
  and o.id is null;

Decida para cada uma se ela recebe o pedido de volta ou vai embora. Apagá-las não tem volta sem uma cópia do banco de antes.

Antes de consertar as linhas

Guarde uma cópia do banco de antes de apagar linhas que apontam para o nada, copiar dados entre projetos ou mudar uma regra, e fique sabendo quando uma tabela começar a responder a estranhos.

Reeve Care guarda uma cópia do seu banco no Supabase:

  • uma cópia criptografada num cronograma, no armazenamento próprio da Reeve na UE, fora da sua conta do Supabase
  • cada linha como estava antes de uma limpeza apagar as erradas
  • cada cópia verificada antes de valer, contando as linhas de cada tabela
  • seus arquivos enviados também, assim que você conecta 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

Reeve Monitor continua conferindo de fora:

  • se seu app está no ar, conferido a cada minuto
  • a verificação de segurança completa a cada hora, em até três apps
  • um e-mail no dia em que uma nova varredura der ao seu app uma nota pior que a anterior, para que uma tabela cujas regras foram desligadas chegue até você sem você ter que ir olhar
  • um relatório mensal, em linguagem simples, do que ele viu

O que fazer agora

O que fazer

  • Leia a tabela na linha de detalhe. No Supabase, users ali quase sempre significa auth.users.
  • Encontre o valor: na linha de detalhe ou na requisição que seu app enviou.
  • Salve primeiro a linha para a qual se aponta, pegue o id dela na resposta e leia cada erro antes do próximo salvamento.
  • Dê not null a uma coluna que precisa apontar para algum lugar, para que um valor vazio também seja recusado.
  • Faça cada regra de inserção conferir de quem é a linha para a qual a nova aponta.
  • Mantenha a chave estrangeira, e liste as linhas que apontam para o nada antes de apagar qualquer uma.

Comece pela tabela da linha de detalhe e pelo id que seu app enviou. Se a correção apaga linhas ou as copia entre projetos, guarde uma cópia do banco de antes de rodar.

Perguntas frequentes

O que significa insert or update on table violates foreign key constraint?

A linha que você tentou salvar aponta para uma linha de outra tabela que não existe, e o Postgres a recusou. Nada foi gravado. A mensagem cita a tabela em que você escreveu e a regra, e a linha de detalhe cita a tabela onde o Postgres procurou. O código é 23503, e de um app Supabase ele chega como HTTP 409.

Por que o erro fala da tabela "users" se eu não tenho nenhuma?

Porque a tabela em questão é quase certamente auth.users, onde o Supabase Auth guarda todas as pessoas cadastradas. O Postgres mostra o nome de uma tabela sem o schema. Sua coluna aponta para uma pessoa, e o id que ela guarda não é de ninguém neste projeto.

O Row Level Security pode causar um erro de chave estrangeira?

Não. O Postgres confere uma chave estrangeira contra cada linha da outra tabela, inclusive as linhas que suas regras escondem, então o erro significa que a linha realmente não está lá. O mesmo fato vale ao contrário: uma linha que aponta para um registro que a pessoa não consegue ver é aceita, a menos que sua regra de inserção confira de quem é esse registro.

Por que a linha de detalhe diz "Key is not present" sem o valor?

O Postgres omite o valor quando o Row Level Security está ligado na tabela em que você escreveu, ou quando quem chama não pode ler essa coluna. O valor é o que seu app enviou, então leia na requisição, na aba Rede das ferramentas de desenvolvedor do navegador.

Devo remover a chave estrangeira para o erro parar?

Não. Os salvamentos passam e as linhas apontam para o nada: um item de pedido sem pedido, um perfil sem ninguém por trás. A regra também decide o que acontece com essas linhas quando um usuário é apagado. Descubra qual linha falta e salve-a primeiro, ou corrija o id.

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.