Saltar al contenido

Fundamentos de seguridad

Error de Supabase "violates foreign key constraint" al insertar

Supabase "insert or update on table violates foreign key constraint": tu fila apunta a una fila que no existe. Cuál, por qué, y cómo arreglarlo.

Vlad Tkachenko14 min de lectura
Una calle de casas numeradas, con un solar vacío bordeado en ámbar, y delante una carta dirigida al número que falta.

En resumen

  • Supabase "violates foreign key constraint" al insertar (código de Postgres 23503, HTTP 409 en tu app) significa que la fila que guardaste nombra una fila de otra tabla que no existe. No se guardó nada. El mensaje nombra tu tabla y la regla, y la línea de detalle nombra la tabla donde buscó Postgres.
  • La fila que falta nunca se guardó, se guardó en segundo lugar, se borró entretanto o vive en otro proyecto. Cuando la línea de detalle dice "users", casi siempre se refiere a auth.users, donde Supabase Auth guarda a todas las personas registradas.
  • La comprobación lee cada fila de la otra tabla, digan lo que digan tus reglas de Row Level Security. Así que también acepta una fila que apunta al registro de otra persona, y tu regla de inserción tiene que comprobar de quién es ese registro.

Pulsas Guardar en un perfil nuevo de tu app y no cambia nada. La página no muestra ningún mensaje. La consola del navegador tiene uno:

{
  "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 la fila que intentaste guardar nombra una fila de otra tabla, y esa fila no existe. Aquí el perfil llevaba un id que debía pertenecer a alguien que se registró, y Postgres no encontró a nadie con ese id. No se guardó nada.

La forma más rápida de que el mensaje desaparezca es borrar la regla. Después los guardados pasan, y las filas que crean apuntan a la nada: una línea de pedido sin pedido, un perfil sin ninguna persona detrás. Averigua primero qué fila falta y por qué. Se perdió de una de unas pocas maneras.

¿Qué significa "violates foreign key constraint" en Supabase?

Significa que una columna de tu fila tiene que coincidir con una fila de otra tabla, el valor que enviaste no coincide con ninguna, y Postgres rechazó la fila entera. La documentación de Postgres sobre restricciones define la regla en una frase: una clave foránea "specifies that the values in a column (or a group of columns) must match the values appearing in some row of another table."

Piensa en la otra tabla como una calle, y en cada una de sus filas como una casa con un número. Tu fila nueva es una carta, y la columna con la clave foránea es el número de casa escrito en ella. Postgres es la oficina de clasificación. Antes de aceptar la carta busca el número en la calle, y una carta para una casa que no existe vuelve en el acto. El mensaje dice qué carta volvió. La línea de detalle dice en qué calle se buscó.

CampoEjemploQué te dice
code23503El código de Postgres para este error, llamado foreign_key_violation en su lista de códigos
messageinsert or update on table "profiles" violates foreign key constraint "profiles_id_fkey"La tabla en la que escribiste, y luego el nombre de la regla
detailsKey (id)=(…) is not present in table "users"., o lo mismo sin el valorLa columna, el valor que enviaste y la tabla donde buscó Postgres. La sección siguiente explica cuándo se omite el valor
hintnullNada que añadir para este error
Estado HTTP409 ConflictPostgREST, la parte de Supabase que convierte las llamadas supabase.from(...) en SQL, asigna 23503 al estado 409 en su código

El texto viene del archivo de Postgres que ejecuta estas comprobaciones. Un update también lo provoca, cuando cambia la columna a un número sin casa, y por eso el mensaje empieza con "insert or update".

Un mensaje que empieza con update or delete on table es la misma regla vista desde la calle: intentaste derribar una casa a la que todavía apuntan cartas. Ese viene de borrar, y el artículo sobre borrar usuarios lo explica.

¿Qué fila está buscando Postgres?

La de la línea de detalle, en la tabla que nombra esa línea. Dos cosas la hacen más difícil de leer de lo que parece.

A veces falta el valor. Postgres escribe Key (id)=(…) solo cuando quien llama puede leer esa columna de tu tabla. Cuando Row Level Security está activada en la tabla en la que escribiste, su código omite el valor, con el comentario "we don't return any specifics to avoid leaking data", y recibes Key is not present in table "users". Eso es lo que ve una petición del navegador a una tabla protegida. El valor es el que envió tu app, así que abre las herramientas de desarrollo del navegador, busca la petición a /rest/v1/profiles en la pestaña Red y léelo en el payload.

Si tu consola muestra el valor en una petición que tu app hizo con la clave pública, Row Level Security está desactivada en la tabla en la que escribiste. Una tabla con sus reglas desactivadas le da sus filas a un desconocido con la misma facilidad que a ti, y activar las reglas en cada tabla explica el interruptor y la primera regla. Nuestro escaneo gratuito lee tu app en producción con la clave pública que recibe cualquier visitante y lista las tablas que le responden. No necesita cuenta.

El nombre de la tabla no lleva esquema. Postgres escribe users, y en un proyecto de Supabase una columna que apunta a personas casi siempre apunta a auth.users, la tabla donde Supabase Auth guarda a todas las personas registradas. No aparece entre tus tablas públicas en el Table Editor, y por eso el mensaje parece nombrar una tabla que nunca creaste.

Para ver exactamente adónde apunta la regla, ejecuta esto en el SQL Editor con el nombre de la regla que trae el mensaje:

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

La respuesta da la tabla que tiene la regla y lo que dice la regla, por ejemplo FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE: tu columna, y luego la tabla y la columna con las que tiene que coincidir. Después busca el valor:

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

Si no vuelve ninguna fila, la persona que nombra tu perfil no existe en este proyecto.

¿De dónde sale una fila que apunta a la nada?

De unos pocos sitios, y la tabla de la línea de detalle te dice dónde mirar primero.

La fila a la que apunta nunca se guardó, o se guardó en segundo lugar. Un pedido y sus líneas son dos guardados. Si tu app guarda primero las líneas, o guarda el pedido, recibe un error que nunca lee y sigue con las líneas, cada línea nombra un pedido que no existe. Guarda primero la fila a la que se apunta, toma su id de la respuesta y lee el error de cada paso antes del siguiente:

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() después de un insert vuelve a leer la fila nueva, así que la persona necesita una regla que le permita leer sus propios pedidos. La documentación de Postgres sobre policies dice que una fila nueva que no pasa las reglas de lectura hace que el guardado lance un error, y en Supabase ese error es new row violates row-level security policy. Los dos guardados son peticiones separadas, así que si fallan las líneas, el pedido ya está guardado: vuelve a enviar las líneas o borra el pedido.

Las líneas guardadas antes que su pedido apuntan a una casa que todavía no existe. Guarda el pedido, toma su id de la respuesta y luego guarda las líneas.

El id vino de otro sitio. Cada proyecto de Supabase tiene su propio auth.users. Un id copiado de tu proyecto de pruebas, uno que un creador con IA dejó en el código desde una vista previa, o filas copiadas de un proyecto a otro nombran todos a personas que el proyecto en producción nunca ha visto. Cuando copies datos entre proyectos, copia las filas a las que se apunta antes que las filas que apuntan a ellas. Las personas detrás de una columna que apunta a auth.users no se copian así: se registran en el proyecto nuevo y reciben ids nuevos al hacerlo. Un perfil cuyo id tiene que ser el id de la persona también se rompe cuando la app lo rellena con un valor aleatorio nuevo, como crypto.randomUUID(). Ese número no es de ninguna casa. Usa user.id de la sesión iniciada.

La fila se borró entretanto. Un producto retirado de tu tienda mientras estaba en el carrito de alguien, o una cuenta borrada en otra pestaña. El guardado que viene después apunta a una casa que existía cuando se cargó la página. El arreglo está en tu app: comprueba error.code === '23503' y dile a la persona qué cambió, por ejemplo "Ese producto ya no está disponible."

Un trigger que se ejecuta demasiado pronto. Un trigger en auth.users que copia a cada persona nueva en profiles tiene que ejecutarse después de que la persona se guarde. La documentación de Postgres sobre triggers dice que el cambio que dispara un trigger BEFORE por fila "is naturally not visible to SQL commands executed in a row-level BEFORE trigger, because it hasn't happened yet". Un perfil escrito desde un trigger before insert apunta, por tanto, a una persona que todavía no existe. Cada registro falla, y Supabase lo informa como "Database error saving new user". El patrón del propio Supabase, que ese artículo reproduce completo, usa after insert.

¿Por qué pasa un valor vacío?

Porque una clave foránea solo comprueba los valores que están. La documentación de Postgres sobre restricciones lo dice así: "Normally, a referencing row need not satisfy the foreign key constraint if any of its referencing columns are null." Null es la palabra de Postgres para ningún valor en absoluto. Una carta sin número de casa nunca se busca. Va al saco y no pertenece a ninguna casa.

Así que una línea de pedido guardada con un order_id vacío se acepta, y no pertenece a ningún pedido. Tu app se la encuentra más tarde, como una línea que no aparece en el carrito de nadie. La misma página da el arreglo: "declare the referencing column(s) as NOT NULL".

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

Después de eso también se rechaza un valor vacío, con un mensaje que nombra la columna directamente, null value in column violates not-null constraint. La sentencia falla mientras ya existan filas con un order_id vacío, así que lístalas primero con select * from public.order_items where order_id is null.

¿Puede una regla estar ocultando la fila que busca?

No. Postgres comprueba una clave foránea contra cada fila de la otra tabla, digan lo que digan tus reglas de Row Level Security. Su documentación sobre la seguridad por filas lo dice sin rodeos: "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." En el código que ejecuta la comprobación, la búsqueda cambia al propietario de la otra tabla, con un indicador que impide que se aplique incluso una regla forzada.

Así que la oficina de clasificación trabaja con el plano completo de la calle. Tu app ve la calle a través de sus reglas, que quizá solo le muestran las casas que puede visitar, y la comprobación nunca usa esa vista. Un 23503 significa que la casa no está para nadie. Si la fila existe y tu app no puede verla, el guardado pasa.

Tu app lee las filas que sus reglas permiten. La comprobación de la clave foránea lee todas las filas, así que se acepta una fila que apunta a una casa que tu app no puede ver.

¿Puede alguien colgar una fila de un registro que no es suyo?

Sí, salvo que tu regla de inserción compruebe de quién es ese registro. La comprobación de que la casa existe ignora tus reglas, así que dice que sí a una casa que es de otra persona.

Toma order_items con esta regla de inserción, que parece segura:

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

Comprueba que la línea es tuya, y nunca mira a qué pedido se suma. Una persona con sesión iniciada que envía su propio user_id con el order_id de otra persona pasa la regla y pasa la clave foránea, y una línea que ella escribió está ahora en el pedido de un desconocido. La misma forma pone un comentario bajo la publicación privada de otra persona, o a un miembro en un equipo al que nadie lo invitó.

Sustitúyela por una regla que mire el 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()
  )
);

Borra la primera regla cuando añadas esta. Postgres combina las reglas del mismo tipo con "OR", en palabras de su documentación sobre policies, así que la antigua seguiría dejando pasar la línea por sí sola.

La comprobación también responde una pregunta a cualquiera que pueda insertar. La frase que sigue a la citada arriba, en la documentación sobre la seguridad por filas, lo advierte: "Care must be taken when developing schemas and row level policies to avoid 'covert channel' leaks of information through such referential integrity checks." Un guardado que falla con 23503 dice que una casa no está. Uno que funciona dice que sí está, en una calle que la persona no puede leer. Los ids de usuario de Supabase son valores aleatorios largos que nadie adivina. Cuando una columna apunta a algo adivinable, como una dirección de correo o un id que cuenta desde 1, una persona puede probar valores de uno en uno y saber cuáles existen.

¿Deberías quitar la clave foránea?

No. Sin ella, el guardado pasa, la fila apunta a la nada, y desde entonces nada te avisa cuando ocurre. Un carrito muestra una línea de un producto que retiraste el mes pasado, y un perfil se queda ahí por una persona que ya no está en auth.users.

La regla también decide qué pasa con esas filas cuando se borra la fila a la que apuntan. Con on delete cascade en la regla que va de profiles a auth.users, borrar a una persona borra su perfil. Sin la regla, el perfil se queda, con los datos personales que contenga. El artículo sobre borrar usuarios explica cómo elegir qué conserva cada tabla.

Si filas antiguas ya apuntan a la nada, lístalas antes de volver a añadir una regla o de endurecer una, porque Postgres se niega a añadir una clave foránea mientras alguna fila la incumpla:

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;

Decide para cada una si recupera su pedido o se va. Borrarlas no tiene vuelta atrás sin una copia de la base de datos de antes.

Antes de reparar las filas

Guarda una copia de la base de datos de antes de borrar filas que apuntan a la nada, copiar datos entre proyectos o cambiar una regla, y entérate cuando una tabla empiece a responder a desconocidos.

Reeve Care guarda una copia de tu base de datos de Supabase:

  • una copia cifrada según un calendario, en el almacenamiento propio de Reeve en la UE, fuera de tu cuenta de Supabase
  • cada fila tal como estaba antes de que una limpieza borrara las que no debía
  • cada copia verificada antes de contar, contando las filas de cada tabla
  • también tus archivos subidos, en cuanto conectas una credencial de Storage
  • una restauración con un clic, con una copia de seguridad del estado actual tomada antes
  • todo lo que hace Monitor

Reeve Monitor sigue comprobando desde fuera:

  • si tu app está en línea, comprobado cada minuto
  • la comprobación de seguridad completa cada hora, en hasta tres apps
  • un correo el día en que un nuevo escaneo califica tu app peor que el anterior, para que una tabla cuyas reglas se desactivaron te llegue sin que tengas que ir a mirar
  • un informe mensual, en lenguaje claro, de lo que vio

Qué hacer ahora

Qué hacer

  • Lee la tabla de la línea de detalle. En Supabase, users ahí casi siempre significa auth.users.
  • Encuentra el valor: en la línea de detalle o en la petición que envió tu app.
  • Guarda primero la fila a la que se apunta, toma su id de la respuesta y lee cada error antes del siguiente guardado.
  • Dale not null a una columna que tiene que apuntar a algo, para que también se rechace un valor vacío.
  • Haz que cada regla de inserción compruebe de quién es la fila a la que apunta la nueva.
  • Mantén la clave foránea, y lista las filas que apuntan a la nada antes de borrar ninguna.

Empieza por la tabla de la línea de detalle y el id que envió tu app. Si el arreglo borra filas o las copia entre proyectos, guarda una copia de la base de datos de antes de ejecutarlo.

Preguntas frecuentes

¿Qué significa insert or update on table violates foreign key constraint?

La fila que intentaste guardar apunta a una fila de otra tabla que no existe, y Postgres la rechazó. No se escribió nada. El mensaje nombra la tabla en la que escribiste y la regla, y la línea de detalle nombra la tabla donde buscó Postgres. El código es 23503, y desde una app de Supabase llega como HTTP 409.

¿Por qué el error habla de la tabla "users" si yo no tengo ninguna?

Porque la tabla a la que se refiere es casi con seguridad auth.users, donde Supabase Auth guarda a todas las personas registradas. Postgres escribe el nombre de una tabla sin su esquema. Tu columna apunta a una persona, y el id que contiene no es de nadie en este proyecto.

¿Puede Row Level Security causar un error de clave foránea?

No. Postgres comprueba una clave foránea contra cada fila de la otra tabla, incluidas las filas que tus reglas ocultan, así que el error significa que la fila de verdad no está. El mismo hecho funciona al revés: una fila que apunta a un registro que la persona no puede ver se acepta, salvo que tu regla de inserción compruebe de quién es ese registro.

¿Por qué la línea de detalle dice "Key is not present" sin el valor?

Postgres omite el valor cuando Row Level Security está activada en la tabla en la que escribiste, o cuando quien llama no puede leer esa columna. El valor es el que envió tu app, así que léelo en la petición, en la pestaña Red de las herramientas de desarrollo de tu navegador.

¿Debería quitar la clave foránea para que el error desaparezca?

No. Los guardados pasan y las filas apuntan a la nada: una línea de pedido sin pedido, un perfil sin nadie detrás. La regla también decide qué pasa con esas filas cuando se borra un usuario. Averigua qué fila falta y guárdala primero, o corrige el id.

Escrito por

Vlad Tkachenko

Fundador de Reeve

Me paso el día mirando apps creadas con Lovable, Bolt, v0, Cursor y Replit, y la corta lista de errores que aparecen en ellas una y otra vez.

Más sobre el autor

Sigue leyendo

Todos los artículos

Haz una copia de tu base de datos de Supabase antes del próximo cambio

Reeve la guarda cifrada con regularidad y la restaura por ti si algo falla.

Ver copias de Supabase

Comprobación externa automatizada, no una auditoría completa. La ausencia de hallazgos no es garantía de seguridad.