Entiscore persiste cada análisis y cada comparativa en Supabase y le asigna un código único, corto y legible, que permite recuperar ese resultado después desde una ruta pública sin necesidad de repetir el análisis. Cuando alguien visita /r/[codigo], el sistema busca ese registro en la base de datos y renderiza el reporte completo si lo encuentra.
El diseño original
El diseño técnico definió dos clientes de Supabase separados por nivel de privilegio desde el principio, no uno solo compartido para todo. Un cliente público, inicializado con la anon key, pensado exclusivamente para las lecturas que ocurren en /r/[codigo]. Un cliente privado, inicializado con la service role key, reservado exclusivamente para las inserciones que ocurren desde los route handlers del servidor cuando se genera un análisis nuevo.
export function getPublicSupabase() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
export function getServerSupabase() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
}
La intención detrás de esta separación era explícita. La service role key bypasea Row Level Security por completo y tiene acceso total a cualquier fila de cualquier tabla, así que su uso debía quedar limitado a la única operación que realmente lo necesitaba, escribir un registro nuevo desde un endpoint del servidor. La lectura pública, al ser justamente pública, no tenía ninguna razón para depender de la credencial más privilegiada del proyecto.
El bug
Generar un análisis funcionaba sin problema, el código único se creaba y se mostraba correctamente en la interfaz. El problema aparecía al intentar acceder a ese mismo código desde /r/[codigo], la página devolvía un 404 de Next.js a pesar de que el registro existía y era perfectamente consultable desde el Table Editor de Supabase.
El diagnóstico se estructuró en varios frentes para descartar causas por separado. Primero se confirmó, mirando directamente el contenido de la tabla en Supabase, que la fila con ese código existía, descartando que el problema fuera de escritura. Segundo se revisó el código de la función encargada de la lectura, confirmando que efectivamente usaba getPublicSupabase con la anon key tal como estaba definido en el diseño. Tercero se confirmó que la ruta tenía configurado export const dynamic = "force-dynamic", descartando que el problema fuera de cacheo estático sirviendo una versión vieja de la página. Cuarto, y el paso que terminó de confirmar la causa, se ejecutó una consulta directa contra Supabase usando el mismo cliente y la misma anon key que usa la aplicación, fuera de Next.js por completo, para aislar el problema del framework.
Esa consulta directa devolvió el resultado clave: ningún dato y ningún mensaje de error. La consulta se ejecutaba sin fallar pero devolvía cero filas.
La causa
La tabla analyses tenía Row Level Security habilitado, como corresponde a cualquier tabla expuesta a un cliente con una credencial pública, pero nunca se había definido una policy de SELECT explícita para el rol anon. Sin esa policy, Postgres, a través del mecanismo de RLS de Supabase, filtra la fila fuera del resultado como si no existiera para ese rol, sin lanzar ninguna excepción, sin devolver ningún código de error HTTP distinto de un 200 con un array vacío. Desde la perspectiva del cliente, la consulta fue exitosa y simplemente no encontró nada que ese rol tuviera permiso de ver.
Ese comportamiento no es un defecto de Supabase ni de Postgres, es el diseño intencional de Row Level Security. Un sistema de permisos a nivel de fila que devolviera un error explícito cuando una fila existe pero el rol no tiene acceso estaría filtrando información, confirmaría la existencia de un dato que el solicitante no debería poder confirmar. El diseño correcto desde el punto de vista de seguridad es que una fila sin permiso se comporte exactamente igual que una fila que no existe.
El problema no estaba en que RLS ocultara la fila, estaba en que nunca se le había dado permiso al rol correcto para verla, y esa ausencia de policy nunca generó ninguna señal de alerta durante el desarrollo, porque las pruebas anteriores probablemente se hicieron con el cliente de service role, que bypasea RLS por completo y nunca hubiera expuesto este problema.
En la ruta /r/[codigo], ese resultado vacío llegaba directo a una condición que invocaba notFound() de Next.js ante la ausencia de datos, produciendo el 404 que se veía en el navegador. La cadena completa terminaba pareciendo un problema de enrutamiento o de caché cuando el origen estaba tres capas más abajo, en una policy de base de datos que nunca se creó.
El primer intento de arreglo, funcional pero incorrecto
Una corrección razonable y rápida frente a este síntoma fue cambiar la función de lectura para que usara getServerSupabase en vez del cliente público. Como la service role key bypasea RLS por completo, la lectura empezaba a funcionar de inmediato.
export async function getAnalysisByCode(code: string) {
const supabase = getServerSupabase() ?? getPublicSupabase();
const { data } = await supabase.from("analyses").select("*").eq("code", code).single();
return data;
}
Funcionalmente, este cambio resolvía el síntoma observable. Pero contradecía la decisión de arquitectura tomada desde el diseño original. La credencial más privilegiada del proyecto pasaba a usarse también para una operación de lectura pública sin ninguna restricción de RLS de por medio, cuando el objetivo explícito de separar los clientes había sido evitar exactamente eso, que una lectura sin necesidad de privilegios elevados dependiera de la clave que puede acceder a cualquier fila de cualquier tabla.
La solución correcta
En vez de aceptar el parche funcional, se revirtió la función de lectura para que volviera a usar el cliente público, y se resolvió el problema en el lugar donde realmente vivía, la base de datos.
create policy "Lectura publica de analyses"
on analyses for select
using (true);
create policy "Lectura publica de comparisons"
on comparisons for select
using (true);
Con estas dos policies aplicadas, el rol anon obtiene permiso explícito de lectura sobre cualquier fila de ambas tablas, que es exactamente el comportamiento deseado dado que el propósito completo del sistema de códigos es que cualquier persona con el código correcto pueda acceder al reporte sin autenticación. La función de lectura volvió a su forma original usando getPublicSupabase sin ningún fallback hacia la credencial privilegiada.
export async function getAnalysisByCode(code: string) {
const supabase = getPublicSupabase();
const { data } = await supabase.from("analyses").select("*").eq("code", code).single();
return data;
}
El mismo síntoma, una causa completamente distinta
Semanas después, durante una rotación de credenciales de Supabase motivada por un incidente de seguridad no relacionado, la misma ruta /r/[codigo] volvió a devolver 404 con códigos que existían en la base. La tentación inmediata fue asumir que se trataba del mismo problema de RLS reapareciendo, pero las policies de SELECT ya estaban confirmadas y activas.
La causa en este segundo caso fue mucho más simple y mucho menos interesante. La variable de entorno con el nuevo valor de la anon key se había actualizado correctamente en el código y en el archivo de entorno local, pero nunca se había pegado el valor nuevo en el panel de variables de entorno de Vercel, así que la aplicación en producción seguía usando la anon key vieja, que ya no era válida tras la rotación. El síntoma observable, un 404 en la misma ruta con datos existentes, era idéntico al del primer caso, pero la causa no tenía absolutamente nada que ver con Row Level Security.
Este segundo episodio es tan relevante como el primero para cualquiera que enfrente un problema similar. El mismo síntoma puede tener causas completamente distintas en momentos distintos, y asumir que ya se conoce la causa porque ya se vio ese error antes es un atajo que puede hacer perder tiempo revisando lo que se sabe que funciona en vez de lo que efectivamente cambió.
Lo que RLS cuesta en un contexto de debugging
Row Level Security que bloquea una lectura sin lanzar ningún error visible no es un bug del sistema, es el comportamiento correcto y deliberado de un modelo de seguridad que prioriza no filtrar la existencia de datos sobre dar retroalimentación clara a quien está depurando el problema. Un resultado vacío sin ningún error adjunto es, muchas veces, la señal más clara de que falta una policy y no de que algo se rompió.
El patrón de diagnóstico que funciona: cuando una lectura de Supabase devuelve cero filas sin error y se puede confirmar que el dato existe en la tabla, el primer punto a revisar son siempre las policies de RLS para el rol que hace el request. El segundo punto, si las policies están confirmadas correctas, es si las credenciales en el entorno coinciden con las activas en Supabase.
Entiscore está disponible en entiscore.vercel.app. Construido con Next.js, TypeScript, Supabase y Claude API para el hackathon Kiro powered by AWS de Código Facilito.

