Por qué usar Supabase RLS para Multi-Tenancy

Tu aplicación SaaS arranca con tres tenants. Las políticas de Supabase RLS lucen bien ajustadas: comprobaciones de team_id en cada tabla, el rol de servicio bajo llave, el middleware pasando los claims JWT correctos. Luego llega el cuarto tenant, y un desarrollador ve el dashboard de otra empresa durante once segundos antes de que tu Slack explote. Bases de datos separadas por tenant habrían evitado esto, claro que sí, pero a $47/mes por instancia de Postgres y con cero capacidad para ejecutar analíticas cruzadas entre tenants, ese cálculo muere rápido. Los esquemas compartidos parecen más sensatos hasta que tu primera migración bloquea 8,000 filas en doce tenants y los tickets de soporte se acumulan. La arquitectura que realmente sobrevive en producción: tablas compartidas con Supabase Row Level Security. Nativa de PostgreSQL, comprobaciones de políticas en submilisegundos, y escribes la lógica de aislamiento una sola vez. Pero RLS en producción tiene siete aristas filosas que no vimos en staging: aquí están todas, junto con los patrones que las remedian.

¿Por qué importa eso? Simple. Tu filtrado de datos ocurre a nivel de base de datos. Si metes la pata en una cláusula WHERE en tu ruta API de Next.js, no te pierdes el sueño pensando en brechas de datos, porque la propia base de datos es tu red de seguridad. Y en realidad, hoy en día eso no es un lujo: es una necesidad.

Pero no nos engañemos. RLS añade sobrecarga a tus consultas, complica la depuración y puede hacerte tropezar durante las migraciones. Entonces, ¿cómo se comparan los distintos enfoques de multi-tenancy?

Enfoque Nivel de Aislamiento Costo Complejidad Operacional Rendimiento de Consultas
Base de datos por tenant Completo Alto ($50-200/tenant/mes) Muy Alto Mejor
Esquema por tenant Fuerte Medio Alto (migraciones) Bueno
Tablas compartidas + RLS A nivel de fila Bajo Medio Bueno (con matices)
Filtrado a nivel de aplicación Ninguno Más bajo Bajo Mejor

Para la mayoría de los productos SaaS con menos de 10,000 tenants, las tablas compartidas con RLS te dan el mejor rendimiento por tu inversión. Es lo que exploraremos aquí.

Multi-Tenant Next.js with Supabase RLS: Production Guide

Patrones de Arquitectura: Compartido vs Aislado

Antes de siquiera pensar en escribir código, tienes que elegir tu estrategia de resolución de tenants. En la práctica, te encontrarás principalmente con dos bestias:

Tenancy Basada en Subdominio

¿Has visto tenant-slug.tuapp.com? Bienvenido al patrón más común para SaaS B2B. Es elegante, profesional, y hace que la resolución de tenants en el middleware sea pan comido.

Tenancy Basada en Ruta

Este es tu básico /org/tenant-slug/dashboard. Más fácil de configurar ya que no requiere DNS con comodines, y funciona en plataformas como Vercel sin dominios personalizados. Pero seamos honestos: se siente un poco como usar calcetines con sandalias. Generalmente recomendamos la basada en subdominio para aplicaciones B2B en producción y la basada en ruta para herramientas internas o MVPs. ¿Cambiar después? Te maldecirás a ti mismo: cambiar estos patrones no es poca cosa.

Configurando el Esquema de Tenant

Aquí tienes un patrón de esquema que no nos ha fallado en tres despliegues de producción diferentes:

-- Core tenant table
CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  plan TEXT NOT NULL DEFAULT 'free',
  created_at TIMESTAMPTZ DEFAULT now(),
  settings JSONB DEFAULT '{}'
);

-- Membership junction table
CREATE TABLE memberships (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
  role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
  created_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE(user_id, org_id)
);

-- Example tenant-scoped table
CREATE TABLE projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID REFERENCES organizations(id) ON DELETE CASCADE NOT NULL,
  name TEXT NOT NULL,
  description TEXT,
  created_by UUID REFERENCES auth.users(id),
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Index on org_id -- you'll need this on EVERY tenant-scoped table
CREATE INDEX idx_projects_org_id ON projects(org_id);
CREATE INDEX idx_memberships_user_id ON memberships(user_id);
CREATE INDEX idx_memberships_org_id ON memberships(org_id);

La tabla memberships es el pegamento que mantiene todo unido. Todas tus políticas RLS apuntarán a ella como si fuera su prima favorita. Los usuarios pueden unirse a múltiples organizaciones, y sus roles dictan lo que pueden o no pueden hacer. Y aquí va una pequeña pepita de sabiduría: siempre, en serio, siempre indexa org_id en cada tabla con ámbito de tenant. De lo contrario, observa cómo tus consultas se arrastran como melaza una vez que estés nadando en datos. Nos sorprendió esto cuando el dashboard de un cliente se desplomó de 50ms a 8 segundos con 100,000 filas. Lección aprendida.

Políticas RLS que Realmente Escalan

Aquí es donde los tutoriales suelen hacer mutis, dejándote abandonado. Te lanzan auth.uid() = user_id y dicen: "¡Buena suerte!" Pero el RLS multi-tenant no se puede simplificar así.

El Patrón de Función Auxiliar

¿Por qué saturar cada política con comprobaciones de membresía? Usa una función auxiliar en su lugar:

-- Helper: check if current user is a member of an org
CREATE OR REPLACE FUNCTION public.is_member_of(org UUID)
RETURNS BOOLEAN AS $$
  SELECT EXISTS (
    SELECT 1 FROM memberships
    WHERE user_id = auth.uid()
    AND org_id = org
  );
$$ LANGUAGE sql SECURITY DEFINER STABLE;

-- Helper: get user's role in an org
CREATE OR REPLACE FUNCTION public.get_role_in(org UUID)
RETURNS TEXT AS $$
  SELECT role FROM memberships
    WHERE user_id = auth.uid()
    AND org_id = org
  LIMIT 1;
$$ LANGUAGE sql SECURITY DEFINER STABLE;

¿Por qué SECURITY DEFINER? Porque la función se ejecuta con los privilegios de su creador, omitiendo RLS en la tabla memberships. Sin esto, corres el riesgo de caer en una madriguera de dependencias circulares donde RLS en memberships destruye las comprobaciones de membresía de las que dependen otras tablas.

¿Y la parte STABLE? Le indica al planificador de consultas que la salida de la función permanece consistente para la misma entrada durante una única consulta, lo que permite algunas ventajas de almacenamiento en caché. ¿Tentado a usar IMMUTABLE? No lo hagas. La membresía puede cambiar entre transacciones.

Políticas para Tablas con Ámbito de Tenant

Veamos algunas políticas para nuestra tabla projects:

-- Enable RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- SELECT: members can view projects in their orgs
CREATE POLICY "Members can view org projects"
  ON projects FOR SELECT
  USING (public.is_member_of(org_id));

-- INSERT: admins and owners can create projects
CREATE POLICY "Admins can create projects"
  ON projects FOR INSERT
  WITH CHECK (
    public.get_role_in(org_id) IN ('owner', 'admin')
  );

-- UPDATE: admins and owners can update projects
CREATE POLICY "Admins can update projects"
  ON projects FOR UPDATE
  USING (public.is_member_of(org_id))
  WITH CHECK (
    public.get_role_in(org_id) IN ('owner', 'admin')
  );

-- DELETE: only owners can delete projects
CREATE POLICY "Owners can delete projects"
  ON projects FOR DELETE
  USING (
    public.get_role_in(org_id) = 'owner'
  );

Políticas para la Propia Tabla de Membresías

Esta es complicada. La tabla memberships tiene su propio RLS, pero no puede usar las funciones auxiliares porque ellas, a su vez, consultan memberships, lo que genera pesadillas de referencias circulares:

ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;

-- Users can see memberships in orgs they belong to
CREATE POLICY "Users can view org memberships"
  ON memberships FOR SELECT
  USING (
    org_id IN (
      SELECT org_id FROM memberships WHERE user_id = auth.uid()
    )
  );

-- Only owners can add members
CREATE POLICY "Owners can add members"
  ON memberships FOR INSERT
  WITH CHECK (
    org_id IN (
      SELECT org_id FROM memberships
      WHERE user_id = auth.uid() AND role = 'owner'
    )
  );

Sí, hay una subconsulta sobre la misma tabla. Y sí, PostgreSQL lo maneja perfectamente. La subconsulta comprueba tu propia membresía, sin verse afectada por la política que se está definiendo, ya que RLS solo envuelve la consulta externa. Pero prueba esto: en serio, no querrás encontrar un bug en producción.

Multi-Tenant Next.js with Supabase RLS: Production Guide - architecture

Middleware de Next.js para la Resolución de Tenants

Con Next.js 15 y el elegante App Router, el middleware que se ejecuta en el edge es el administrador perfecto para la resolución de tenants. Aquí está nuestro patrón de confianza para configuraciones basadas en subdominio:

// middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const PUBLIC_ROUTES = ['/login', '/signup', '/invite'];

export async function middleware(request: NextRequest) {
  const hostname = request.headers.get('host') || '';
  const currentHost = hostname.split('.')[0];

  // Skip for main domain and localhost
  const isMainDomain = currentHost === 'app' || currentHost === 'www' || currentHost === 'localhost:3000';

  let response = NextResponse.next({
    request: { headers: request.headers },
  });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) => {
            request.cookies.set(name, value);
            response.cookies.set(name, value, options);
          });
        },
      },
    }
  );

  const { data: { user } } = await supabase.auth.getUser();

  if (!isMainDomain) {
    response.headers.set('x-tenant-slug', currentHost);

    if (!user && !PUBLIC_ROUTES.some(r => request.nextUrl.pathname.startsWith(r))) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }

  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)'],
};

El header x-tenant-slug es oro puro. Úsalo para que tus Server Components y rutas API sepan con qué tenant están tratando. Si colaboras con nosotros en un proyecto Next.js, configurar esto es nuestra prioridad del día uno.

Flujo de Autenticación en Aplicaciones Multi-Tenant

Supabase Auth se mantiene neutral en el juego del multi-tenancy. Los usuarios existen en una esfera global: las relaciones con los tenants son tu rompecabezas a resolver. Aquí está nuestro plan de juego:

  1. El usuario se registra: Crea un usuario de auth, construye una organización y genera una membresía con el rol de 'owner'.
  2. El usuario es invitado: El admin crea una invitación pendiente, un nuevo usuario se une mediante el enlace de invitación y, de la nada, aparece una membresía con el rol especificado.
  3. El usuario inicia sesión: Extrae el tenant del subdominio, confirma la membresía y lo escolta a su dashboard.
// app/api/auth/signup/route.ts
import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const { email, password, orgName, orgSlug } = await request.json();
  const supabase = await createClient();

  // Sign up the user
  const { data: authData, error: authError } = await supabase.auth.signUp({
    email,
    password,
  });

  if (authError) return NextResponse.json({ error: authError.message }, { status: 400 });

  // Use a service role client for org creation (bypasses RLS)
  const adminClient = createAdminClient();

  const { data: org, error: orgError } = await adminClient
    .from('organizations')
    .insert({ name: orgName, slug: orgSlug })
    .select()
    .single();

  if (orgError) return NextResponse.json({ error: orgError.message }, { status: 400 });

  // Create ownership membership
  await adminClient
    .from('memberships')
    .insert({
      user_id: authData.user!.id,
      org_id: org.id,
      role: 'owner',
    });

  return NextResponse.json({ org });
}

Fíjate en que usamos un cliente de rol de servicio durante el registro. El usuario aún no tiene ninguna membresía, por lo que RLS lo dejaría en la estacada para la creación de la organización. Es uno de esos problemas clásicos de arranque: tu clave de rol de servicio será tu varita mágica.

Y no puedo enfatizarlo suficiente: nunca, jamás, expongas tu clave de rol de servicio al cliente. Es estrictamente para código del lado del servidor.

Server Components y RLS: El Problema del SSR

Los Server Components de Next.js 15 están ligados al servidor, mejorando el juego de seguridad. Pero hay un tropiezo al usar Supabase RLS: tienes que proporcionar la sesión del usuario al cliente de Supabase para que las políticas RLS sepan quién está en la mesa.

// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // This can fail in Server Components (read-only)
            // The middleware handles cookie refreshing
          }
        },
      },
    }
  );
}
// app/[orgSlug]/projects/page.tsx
import { createClient } from '@/lib/supabase/server';
import { headers } from 'next/headers';

export default async function ProjectsPage() {
  const supabase = await createClient();
  const headersList = await headers();
  const tenantSlug = headersList.get('x-tenant-slug');

  // Get the org ID from slug
  const { data: org } = await supabase
    .from('organizations')
    .select('id')
    .eq('slug', tenantSlug)
    .single();

  if (!org) return <div>Organization not found</div>;

  // RLS automatically filters -- only returns projects
  // where the current user has membership
  const { data: projects } = await supabase
    .from('projects')
    .select('*')
    .eq('org_id', org.id)
    .order('created_at', { ascending: false });

  return (
    <div>
      {projects?.map(project => (
        <ProjectCard key={project.id} project={project} />
      ))}
    </div>
  );
}

Aquí está lo clave: incluso si alguien manipula el org_id en la solicitud, RLS no cederá. Bloquea el acceso a los proyectos a menos que el usuario sea miembro. Técnicamente, .eq('org_id', org.id) es redundante para la seguridad, ya que RLS se encarga de eso, pero es bueno para el rendimiento y la legibilidad.

Optimización del Rendimiento y Errores Comunes

El Problema de Consultas N+1 con RLS

Cada comprobación de política RLS genera una subconsulta. Engancharse a una comprobación de política de 10 filas cuando estás mirando 100 filas significa 100 rondas de búsqueda de membresía. Por suerte, PostgreSQL es suficientemente inteligente para hacer caché, pero hay sobrecarga.

Solución: Usa STABLE en las funciones auxiliares (como describimos anteriormente). Además, piensa en desnormalizar org_id en los claims del JWT:

-- Custom JWT hook (Supabase Dashboard > Auth > Hooks)
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event JSONB)
RETURNS JSONB AS $$
DECLARE
  org_ids UUID[];
BEGIN
  SELECT array_agg(org_id) INTO org_ids
  FROM memberships
  WHERE user_id = (event->>'user_id')::UUID;

  event := jsonb_set(
    event,
    '{claims,org_ids}',
    to_jsonb(org_ids)
  );

  RETURN event;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

Entonces tu política RLS se convierte en:

CREATE POLICY "Members can view"
  ON projects FOR SELECT
  USING (
    org_id = ANY(
      (SELECT array(SELECT jsonb_array_elements_text(
        auth.jwt()->'org_ids'
      ))::UUID[])
    )
  );

Esto elimina por completo la búsqueda en la tabla de membresías. Los IDs de las organizaciones provienen directamente del JWT. Advertencia: Los claims del JWT se sellan en el momento del inicio de sesión. Cambia la membresía de alguien y necesitará volver a autenticarse para sincronizar los claims. Normalmente, eso es perfectamente manejable: solo mantenlo documentado.

Connection Pooling

Supabase ofrece connection pooling a través de PgBouncer. Si vas a producción con Next.js en Vercel, recuerda: URL del pooler para rutas API y server components.

# For regular operations (pooled)
DATABASE_URL=postgres://user:pass@db.project.supabase.co:6543/postgres

# For migrations only (direct)
DIRECT_URL=postgres://user:pass@db.project.supabase.co:5432/postgres

Cualquiera en el plan Pro de Supabase por $25 al mes obtiene 200 conexiones simultáneas a través del pooler. Para la mayoría de las aplicaciones SaaS con menos de 1000 usuarios concurrentes, es más que suficiente.

Índices que Absolutamente Necesitas

Aquí está el conjunto de índices esenciales para una configuración multi-tenant:

-- On every tenant-scoped table
CREATE INDEX idx_{table}_org_id ON {table}(org_id);

-- Composite indexes for common queries
CREATE INDEX idx_projects_org_created ON projects(org_id, created_at DESC);

-- Memberships -- heavily queried by RLS
CREATE INDEX idx_memberships_user_org ON memberships(user_id, org_id);
CREATE INDEX idx_memberships_org_role ON memberships(org_id, role);

EXPLAIN ANALYZE es el mejor amigo de un desarrollador. Observa cómo se comportan tus consultas con RLS activado. Puede que te lleves un susto con lo que decide hacer el planificador sin los índices correctos.

Probando las Políticas RLS

Todo el mundo se salta esto, y sin embargo es tu mejor red de seguridad contra las filtraciones de datos. Probamos las políticas RLS directamente en SQL:

-- Test as a specific user
SET request.jwt.claims = '{"sub": "user-uuid-here", "role": "authenticated"}';
SET role = 'authenticated';

-- This should return only projects the user has access to
SELECT * FROM projects;

-- This should fail (user not a member of this org)
INSERT INTO projects (org_id, name) VALUES ('other-org-uuid', 'Sneaky Project');

-- Reset
RESET role;

Y no olvidemos pgTAP para políticas críticas:

BEGIN;
SELECT plan(3);

-- Set up test context as user A (member of org 1)
SET LOCAL request.jwt.claims = '{"sub": "user-a-uuid"}';
SET LOCAL role = 'authenticated';

SELECT is(
  (SELECT count(*) FROM projects WHERE org_id = 'org-1-uuid')::INTEGER,
  5,
  'User A sees 5 projects in their org'
);

SELECT is(
  (SELECT count(*) FROM projects WHERE org_id = 'org-2-uuid')::INTEGER,
  0,
  'User A sees 0 projects in other org'
);

SELECT throws_ok(
  $$INSERT INTO projects (org_id, name) VALUES ('org-2-uuid', 'Hack')$$,
  'new row violates row-level security policy',
  'User A cannot insert into other org'
);

SELECT * FROM finish();
ROLLBACK;

Ejecuta estos en CI. Cada migración que toque políticas RLS debe someter la suite de pruebas completa a un ejercicio riguroso.

Lista de Verificación para el Despliegue en Producción

¿Listo para lanzar? Prepárate con esto:

  • RLS habilitado en cada tabla que alberga datos de tenants
  • Clave de rol de servicio guardada del lado del servidor, sin acercarse jamás a un cliente
  • org_id debidamente indexado en todas las tablas con ámbito de tenant
  • Funciones auxiliares de membresía designadas como SECURITY DEFINER y STABLE
  • Claims personalizados del JWT listos y cargados (si estás en la ruta JWT)
  • ¿Tienes connection pooling configurado para el despliegue en la nube?
  • Políticas RLS recién salidas de pruebas de control de calidad con pgTAP o similar
  • ¿Ejecutaste EXPLAIN ANALYZE en consultas cruciales con RLS activo?
  • El flujo de invitación/registro no tiene ningún bootstrap de membresía faltante
  • ¿Algún rate limiting en los endpoints de auth? Supabase ofrece opciones integradas
  • Activa RLS para las tablas del esquema auth en el Supabase Dashboard (a menudo una mina terrestre)
  • Monitoreo integrado para consultas lentas (Supabase Dashboard > Database > Query Performance)

¿Lanzando un producto multi-tenant y quieres a alguien que ya haya navegado estas aguas antes? Nuestras soluciones de desarrollo headless CMS o una charla rápida a través de nuestra página de contacto podría ser justo lo que necesitas.

Preguntas Frecuentes

¿Puedo usar Supabase RLS para aplicaciones con miles de tenants? Absolutamente. Hemos pilotado RLS con tablas compartidas con más de 5,000 tenants y millones de filas sin sudar. ¿La salsa secreta? Indexación adecuada en las columnas org_id y funciones auxiliares STABLE. ¿Considerando más de 50,000 tenants o extravaganzas de miles de millones de filas? Adéntrate en el particionamiento de tablas por org_id o flirtea con una configuración de esquema por tenant.

¿Cómo gestiono el cambio de tenant cuando un usuario pertenece a múltiples organizaciones? Guarda la organización activa en una cookie o URL (subdominio). ¿Cambiar de org? Ajusta el subdominio/cookie y vuelve a buscar. No guardes la org activa en el JWT: exige un nuevo inicio de sesión para cambiar. Una cookie que tu middleware pueda leer es el camino correcto.

¿Qué pasa si olvido habilitar RLS en una tabla? Cada usuario autenticado podría acceder a cada fila. Esa es la postura predeterminada de PostgreSQL: sin restricciones a nivel de fila en tablas sin RLS. El Supabase Dashboard señala las tablas que no tienen RLS, pero también ayuda incrustar esto en CI con consultas a pg_tables y pg_policies.

¿Debo usar la clave de rol de servicio de Supabase o crear un rol PostgreSQL personalizado para tareas de administración? En su mayor parte, la clave de rol de servicio es suficiente. Esquiva RLS por completo, así que es tu secreto mejor guardado solo para uso del lado del servidor. ¿Necesitas una gobernanza más granular (como un rol "admin" presente en todas las organizaciones pero sin capacidad para eliminar)? Eso es territorio de PostgreSQL personalizado: avanzado y generalmente fuera de tu radar hasta que las herramientas internas complejas lo demanden.

¿Cómo ejecuto migraciones de base de datos sin tropezar con las políticas RLS? El CLI de Supabase (supabase db push o supabase migration) junto con la URL de base de datos directa (omitiendo la pooled) te respalda. Mete las ediciones de políticas RLS en la misma migración que los cambios de esquema. Prueba las migraciones contra un proyecto de staging: Supabase te permite crear ramas de vista previa en Pro justamente para este tipo de cosas.

¿Pueden las políticas RLS acceder a datos de otras APIs o servicios? No. Las políticas RLS viven cómodamente en SQL, evaluadas por PostgreSQL. ¿Quieres consultar datos externos (como un servicio de feature flags)? Solidifica esos datos en una tabla de base de datos y luego refiérete a ella en tu política. Un patrón típico es sincronizar los estados de suscripción desde Stripe a una columna organizations.plan.

¿Cuál es el impacto en el rendimiento de RLS en comparación con el filtrado en la capa de aplicación? En nuestros benchmarks con Supabase Pro (2 vCPUs, 8GB RAM), RLS añade entre 1 y 3ms adicionales por consulta para políticas básicas de comprobación de membresía con los índices correctos. Si complicas las políticas o los joins podrías añadir entre 5 y 15ms. La táctica de los claims del JWT (almacenando org_ids en el token) lo recorta por debajo de 1ms ya que no hay danza de subconsultas. Para las aplicaciones web típicas, esa pequeña latencia es insignificante.

¿Cómo funciona esto con las suscripciones de Supabase Realtime? Supabase Realtime sigue las reglas de RLS. Sintoniza los cambios de tabla y recibe solo los eventos de filas que tienes permiso de ver según RLS. Esto funciona de fábrica sin ningún ajuste adicional. Solo asegúrate de que tu Supabase del lado del cliente tenga la sesión del usuario, algo que @supabase/ssr gestiona sin problemas.

Conclusión clave: Las tablas compartidas con RLS reducen costos pero exigen una disciplina estricta en políticas e índices.