La seguridad vive en la base de datos: RLS y permisos por tabla con Supabase
Un middleware que redirige a login no es autorización, es enrutado. Cómo reparto la seguridad entre el proxy, una tabla de permisos y las políticas RLS, y por qué la última capa es la única que cuenta.
SupabaseSeguridadPostgreSQLNext.js
Hay una forma cómoda y equivocada de proteger una aplicación: un middleware que comprueba si hay sesión, redirige a /login si no, y a partir de ahí confiar.
Eso no es autorización. Es enrutado. La diferencia se nota el día en que una consulta se te va sin filtrar por usuario.
Así reparto yo la seguridad en mis proyectos con Supabase, en tres capas, de la menos importante a la más importante.
Capa 1: el proxy (la menos importante)
En Next.js 16 el middleware.ts de la raíz se llama ahora proxy.ts. En este portfolio es cuatro líneas y un matcher:
export const config = {
matcher: [
// Solo proteger rutas de dashboard
'/dashboard/:path*',
],
}
Refresca la sesión de Supabase y, si no hay usuario en una ruta de dashboard, redirige a /login.
Lo que hace bien: experiencia de usuario. Nadie ve una página vacía ni un error; ve la pantalla de login. Y mantiene la sesión viva, que es imprescindible cuando renderizas en servidor.
Lo que no hace: autorizar. Solo sabe si hay sesión, no qué puede hacer esa sesión. Y solo cubre las rutas del matcher.
Un detalle que aprendí a base de un bug muy desagradable, y que está anotado en el código con un comentario en mayúsculas: no metas nada entre createServerClient y getClaims(). Si lo haces, te aparecen usuarios expulsados de la sesión aleatoriamente y no hay manera humana de reproducirlo.
Capa 2: permisos por tabla, comprobados dos veces
Un usuario autenticado no es un usuario autorizado. En este portfolio hay dos tablas, permissions y user_permissions, y dos claves: blog.manage y projects.manage.
El layout del dashboard exige al menos una de las dos, o te echa:
const hasBlogPermission = await checkPermissionServer('blog.manage')
if (!hasBlogPermission) {
redirect('/dashboard')
}
Eso parece redundante y no lo es. El layout responde a "¿tienes algo que hacer aquí?"; la página responde a "¿puedes hacer esto?". Alguien con solo projects.manage entra al dashboard, ve su sección de proyectos, y si escribe /dashboard/blog en la barra de direcciones acaba de vuelta en /dashboard.
El mismo permiso gobierna la navegación: el submenú recibe los dos booleanos y no pinta enlaces a secciones que no puedes usar. Ocultar el enlace no es seguridad —la comprobación de la página sí lo es— pero es la diferencia entre una interfaz que se entiende y una llena de callejones sin salida.
El coste de este diseño es que añadir una sección al dashboard son cuatro sitios: una clave nueva en el tipo PermissionKey, una fila en la tabla permissions, la comprobación en el layout y la comprobación en la página. Es tedioso a propósito. Un sistema de permisos que se puede ampliar por descuido no es un sistema de permisos.
Capa 3: RLS (la única que de verdad cuenta)
Las dos capas anteriores viven en el código de la aplicación. Y el código de la aplicación tiene bugs.
En BibLib —mi biblioteca personal de libros— la migración inicial no crea solo tablas. Crea el schema biblib, las tablas, los índices, las constraints y las políticas RLS por usuario sobre auth.uid() para las cuatro operaciones: SELECT, INSERT, UPDATE y DELETE. Todo en el mismo fichero versionado, drizzle/0000_biblib_init.sql.
Las cuatro, no solo SELECT. Es un error clásico proteger la lectura y dejar el UPDATE abierto: entonces no puedes ver los libros de otro, pero puedes modificarlos.
Lo que compra esto es concreto: si mañana escribo una consulta y me olvido del WHERE user_id = ..., no hay filtración. Postgres no me va a devolver filas que no son mías, porque la política no lo permite. El fallo pasa de ser una brecha de datos a ser un resultado vacío que noto en desarrollo.
Ese es el criterio con el que decido dónde poner una regla: si el fallo por olvido es catastrófico, la regla va en la base de datos. Si el fallo por olvido es una molestia, puede vivir en la aplicación.
Por qué las tres, y en este orden
Se solapan a propósito:
El proxy hace que la experiencia sea correcta. Si falla, alguien ve una página fea.
Los permisos hacen que la aplicación sea coherente. Si fallan, alguien accede a una sección que no le toca y ve datos que no debería.
RLS hace que la brecha sea imposible. Si falla, es un incidente de verdad.
Fíjate en la asimetría: las dos primeras capas fallan de forma barata; la tercera es la que no puede fallar. Por eso es la que vive donde no la puede saltar ningún código de aplicación, ni el mío ni el de un agente que me escriba una consulta a medianoche.
Y esto último no es retórica. Cuando trabajo con un agente sobre estos proyectos, RLS es lo que hace que un error suyo sea un SELECT vacío en lugar de una filtración. No confío en que el código generado acierte siempre con el filtro por usuario. Confío en que la base de datos no le deje equivocarse.
Lo mínimo que dejaría en cualquier proyecto
Si tuviera que reducirlo a una lista corta:
RLS activado en todas las tablas con datos de usuario, con políticas para las cuatro operaciones, escritas en la migración inicial y no a mano en el dashboard.
Autorización comprobada en el servidor en cada punto de entrada, no solo en el que se te ocurre primero.
El middleware o proxy, solo para experiencia de usuario y sesión. Si es tu única capa, no tienes autorización.
Las políticas en control de versiones. Una política que solo existe porque alguien la pulsó en una interfaz web no es reproducible, y no la vas a poder revisar en un pull request.