Supabase RLS: Notas Privadas que SOLO tú puedes ver (sin backend propio)
30 de agosto de 2026 · Supabase Row Level Security · Stack: Supabase Row Level Security, Next.js 14 App Router, Supabase Auth, TypeScript, Tailwind CSS
Sé el primero en decir si sirve
AtlasConstruye una app de notas donde cada usuario solo puede leer y escribir SUS propios datos, usando Row Level Security de Supabase sin escribir ni una línea de backend.
Sales con esto
- RLS se activa por tabla con ALTER TABLE ... ENABLE ROW LEVEL SECURITY y es DENY ALL por defecto
- auth.uid() es una función de Supabase que retorna el UUID del usuario autenticado en la sesión actual
- Las políticas FOR SELECT con USING filtran automáticamente; FOR INSERT usa WITH CHECK para validar
- Un SELECT * FROM notes sin WHERE retorna solo las filas del usuario autenticado — la protección es a nivel de motor de base de datos, no de aplicación
- La anon key es segura de exponer en el frontend porque RLS restringe qué datos puede retornar
Capítulos
- 0:00Mis Notas — secure-notes-rls
- 0:27El problema: protección solo en la app
- 0:56La solución: RLS en la base de datos
- 1:43supabase/migrations/20240101_notes_rls.sql
- 2:33src/lib/supabase/client.ts
- 2:53src/lib/supabase/server.ts
- 3:20src/app/api/notes/route.ts
- 4:13src/app/page.tsx
- 4:52src/app/notes/page.tsx
- 6:05Notas Seguras
- 6:17Mis Notas — secure-notes-rls
- 6:35Errores comunes con RLS
- 7:08Pro Tip: Testear RLS en el SQL Editor sin autenticarte
- 7:40Lo que aprendiste hoy
Necesitas
- Cuenta en Supabase
- Node.js 18+
- Conocimiento básico de SQL y React
Desarrolladores frontend o fullstack que usan Supabase y quieren seguridad real sin manejar un backend propio.
6 pasos, un proyecto que corre
Paso 1 — Migración SQL: tabla + políticas RLS
Entender cómo se habilita RLS y se crean políticas que usan auth.uid() para aislar filas por usuario
supabase/migrations/20240101_notes_rls.sql
-- 1. Crear tabla de notas
CREATE TABLE IF NOT EXISTS notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 2. Habilitar Row Level Security
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
-- 3. Política: solo puedes VER tus propias notas
CREATE POLICY "Users can view own notes"
ON notes FOR SELECT
USING (auth.uid() = user_id);
-- 4. Política: solo puedes INSERTAR notas con tu user_id
CREATE POLICY "Users can insert own notes"
ON notes FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- 5. Política: solo puedes ELIMINAR tus propias notas
CREATE POLICY "Users can delete own notes"
ON notes FOR DELETE
USING (auth.uid() = user_id);
-- Verificar que RLS está activo
SELECT tablename, rowsecurity
FROM pg_tables
WHERE tablename = 'notes';comando: # Ejecutar en el SQL Editor de Supabase Dashboard, o via CLI: npx supabase db push
Paso 2 — Cliente Supabase para el browser
Crear un cliente Supabase singleton que persiste la sesión del usuario en cookies para el App Router
src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
// Tipos para nuestra tabla
export type Note = {
id: string
user_id: string
title: string
content: string | null
created_at: string
}Paso 3 — Cliente Supabase para Server Components
Aprender la diferencia entre el cliente de browser y el de servidor, necesario para leer cookies en RSC
src/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 {
// En Server Components no se pueden setear cookies.
// El middleware se encarga de refrescar la sesión.
}
}
}
}
)
}Paso 4 — API Route: crear y listar notas
Ver cómo el servidor nunca necesita filtrar por user_id manualmente; RLS lo hace automáticamente
src/app/api/notes/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
// GET /api/notes — RLS filtra automáticamente, solo devuelve las del usuario autenticado
export async function GET() {
const supabase = await createClient()
// ⚡ No necesitamos .eq('user_id', userId) — RLS lo hace por nosotros
const { data: notes, error } = await supabase
.from('notes')
.select('*')
.order('created_at', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ notes })
}
// POST /api/notes — RLS rechaza el insert si user_id !== auth.uid()
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'No autenticado' }, { status: 401 })
}
const { title, content } = await request.json()
if (!title?.trim()) {
return NextResponse.json({ error: 'El título es requerido' }, { status: 400 })
}
const { data: note, error } = await supabase
.from('notes')
.insert({
user_id: user.id, // RLS valida que esto coincida con auth.uid()
title: title.trim(),
content: content?.trim() || null
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ note }, { status: 201 })
}Paso 5 — Página de login con magic link
Implementar autenticación passwordless con Supabase Auth que activa el contexto de RLS
src/app/page.tsx
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [sent, setSent] = useState(false)
const [loading, setLoading] = useState(false)
const supabase = createClient()
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
const { error } = await supabase.auth.signInWithOtp({
email,
options: { emailRedirectTo: `${window.location.origin}/notes` }
})
if (error) {
alert(error.message)
} else {
setSent(true)
}
setLoading(false)
}
if (sent) {
return (
<main className="min-h-screen flex items-center justify-center bg-slate-950">
<div className="text-center">
<p className="text-2xl">📬</p>
<p className="text-white mt-2">Revisa tu email para el magic link</p>
</div>
</main>
)
}
return (
<main className="min-h-screen flex items-center justify-center bg-slate-950">
<form onSubmit={handleLogin} className="bg-slate-900 p-8 rounded-xl w-full max-w-sm space-y-4">
<h1 className="text-white text-2xl font-bold">🔒 Notas Seguras</h1>
<p className="text-slate-400 text-sm">RLS garantiza que nadie más puede ver tus notas</p>
<input
type="email"
placeholder="tu@email.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
className="w-full px-4 py-2 rounded-lg bg-slate-800 text-white border border-slate-700 focus:outline-none focus:border-indigo-500"
/>
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded-lg bg-indigo-600 text-white font-semibold hover:bg-indigo-500 disabled:opacity-50 transition"
>
{loading ? 'Enviando...' : 'Entrar con Magic Link'}
</button>
</form>
</main>
)
}comando: npm run dev
Pantalla oscura centrada con formulario de login: título '🔒 Notas Seguras', subtítulo sobre RLS, input de email y botón 'Entrar con Magic Link' en indigo.
Paso 6 — Dashboard de notas protegido por RLS
Observar que el SELECT sin filtros retorna SOLO las notas del usuario autenticado gracias a RLS
src/app/notes/page.tsx
'use client'
import { useEffect, useState } from 'react'
import { createClient, type Note } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
export default function NotesPage() {
const [notes, setNotes] = useState<Note[]>([])
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [loading, setLoading] = useState(true)
const [userEmail, setUserEmail] = useState('')
const supabase = createClient()
const router = useRouter()
useEffect(() => {
async function init() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) { router.push('/'); return }
setUserEmail(user.email ?? '')
await fetchNotes()
}
init()
}, [])
async function fetchNotes() {
setLoading(true)
const res = await fetch('/api/notes')
const { notes } = await res.json()
setNotes(notes ?? [])
setLoading(false)
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
const res = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content })
})
if (res.ok) {
setTitle(''); setContent('')
await fetchNotes()
}
}
async function handleLogout() {
await supabase.auth.signOut()
router.push('/')
}
return (
<main className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">📝 Mis Notas</h1>
<p className="text-slate-400 text-sm">Sesión: {userEmail}</p>
</div>
<button onClick={handleLogout} className="text-sm text-slate-400 hover:text-white">
Cerrar sesión
</button>
</div>
{/* Formulario para crear nota */}
<form onSubmit={handleCreate} className="bg-slate-900 p-4 rounded-xl space-y-3">
<input
placeholder="Título de la nota"
value={title}
onChange={e => setTitle(e.target.value)}
required
className="w-full px-3 py-2 rounded-lg bg-slate-800 border border-slate-700 focus:outline-none focus:border-indigo-500"
/>
<textarea
placeholder="Contenido (opcional)"
value={content}
onChange={e => setContent(e.target.value)}
rows={3}
className="w-full px-3 py-2 rounded-lg bg-slate-800 border border-slate-700 focus:outline-none focus:border-indigo-500 resize-none"
/>
<button
type="submit"
className="px-4 py-2 bg-indigo-600 rounded-lg hover:bg-indigo-500 text-sm font-semibold transition"
>
+ Agregar nota
</button>
</form>
{/* Lista de notas — solo las del usuario gracias a RLS */}
<div className="space-y-3">
{loading && <p className="text-slate-500">Cargando...</p>}
{!loading && notes.length === 0 && (
<p className="text-slate-500">No tienes notas aún. ¡Crea la primera!</p>
)}
{notes.map(note => (
<div key={note.id} className="bg-slate-900 p-4 rounded-xl border border-slate-800">
<p className="font-semibold">{note.title}</p>
{note.content && <p className="text-slate-400 text-sm mt-1">{note.content}</p>}
<p className="text-slate-600 text-xs mt-2">
{new Date(note.created_at).toLocaleString('es-MX')}
</p>
</div>
))}
</div>
{/* Bloque educativo: muestra que RLS filtra sin código */}
<div className="bg-indigo-950 border border-indigo-800 p-4 rounded-xl text-sm">
<p className="font-semibold text-indigo-300">¿Por qué no ves notas de otros usuarios?</p>
<p className="text-indigo-400 mt-1">
La query es <code className="bg-slate-800 px-1 rounded">SELECT * FROM notes</code> — sin filtros.
RLS aplica automáticamente <code className="bg-slate-800 px-1 rounded">WHERE user_id = auth.uid()</code> a nivel de base de datos.
</p>
</div>
</div>
</main>
)
}Dashboard con el email del usuario en la cabecera, formulario para crear notas, lista de notas del usuario (vacía o con datos), y un bloque educativo indigo que explica por qué RLS filtra sin código adicional.
Lo que se rompe
¿Qué problema resuelve Supabase RLS: Notas Privadas que SOLO tú puedes ver (sin backend propio)?
En apps multi-usuario, proteger los datos de cada usuario normalmente requiere lógica en el servidor. Con RLS, esa protección vive directamente en la base de datos y es imposible saltársela. Construye una app de notas donde cada usuario solo puede leer y escribir SUS propios datos, usando Row Level Security de Supabase sin escribir ni una línea de backend.
¿Qué necesito saber antes de seguir esta clase?
Necesitas Cuenta en Supabase, Node.js 18+, Conocimiento básico de SQL y React. La clase es de nivel intermedio y dura 8 minutos.
¿Qué stack se usa para Supabase Row Level Security?
El proyecto usa Supabase Row Level Security, Next.js 14 App Router, Supabase Auth, TypeScript, Tailwind CSS. Todo el código se escribe en pantalla durante la clase.
¿Por qué falla al olvidar ENABLE ROW LEVEL SECURITY: la tabla existe pero cualquiera puede leer todos los datos?
Olvidar ENABLE ROW LEVEL SECURITY: la tabla existe pero cualquiera puede leer todos los datos
¿Por qué falla al usar el service_role key en el cliente de browser: esta key BYPASEA RLS completamente, solo usarla en servidor?
Usar el service_role key en el cliente de browser: esta key BYPASEA RLS completamente, solo usarla en servidor
¿Por qué falla al no crear políticas después de activar RLS: con RLS activo y sin políticas, NADIE puede leer nada (ni el…?
No crear políticas después de activar RLS: con RLS activo y sin políticas, NADIE puede leer nada (ni el propio usuario)
¿Por qué falla al insertar user_id hardcodeado desde el cliente esperando que RLS lo valide: si envías un user_id falso, la…?
Insertar user_id hardcodeado desde el cliente esperando que RLS lo valide: si envías un user_id falso, la política WITH CHECK lo rechazará con un error 42501
¿Hay algún truco que no esté en la documentación oficial?
Puedes testear tus políticas RLS directamente en el SQL Editor de Supabase sin autenticarte: usa SET LOCAL role = authenticated; SET LOCAL request.jwt.claims = '{"sub": "uuid-del-usuario"}'; antes de tu query. Esto simula una sesión autenticada y te permite depurar políticas sin tocar tu app.
Transcripción
Transcripción con marcas de tiempo
0:00¿Qué pasaría si te digo que puedes proteger los datos de cada usuario sin escribir ni una línea de backend? Sin middleware, sin guards, sin lógica de filtrado en tu servidor. Solo SQL. Eso es exactamente lo que vamos a construir hoy: una app de notas donde cada usuario solo puede ver las suyas, garantizado a nivel de base de datos.
0:27El problema clásico en apps multi-usuario es este: tienes una tabla de notas, y si alguien llama directamente a tu API con el ID de otro usuario, puede leer sus datos. La solución típica es agregar lógica en el servidor para filtrar. Pero esa lógica puede tener bugs, puede olvidarse en algún endpoint, o simplemente alguien puede saltársela si tiene acceso directo a la base de datos.
0:56Row Level Security, o RLS, mueve esa protección directamente al motor de base de datos. Cuando está activo, cada query se ejecuta con el contexto del usuario autenticado. Da igual si el backend olvida el filtro, da igual si alguien accede directo a Postgres. La base de datos misma aplica las reglas y devuelve solo las filas permitidas. Es imposible saltárselo desde la aplicación.
1:23Vamos al código. Primero el setup. Creamos el proyecto con Next.js 14 usando el App Router, TypeScript y Tailwind. Después instalamos el SDK de Supabase con el paquete SSR que maneja las cookies correctamente en el App Router. Y copiamos el archivo de variables de entorno.
1:43El primer paso real es la migración SQL. Aquí vive toda la magia. Creamos la tabla de notas con un campo user_id que referencia a auth.users, la tabla interna de Supabase Auth. Esta referencia es crítica: vincula cada nota a un usuario real del sistema de autenticación.
2:04Ahora las políticas. Tres en total. La primera controla SELECT: solo puedes leer filas donde tu user_id coincide con auth.uid(), que es la función que Supabase inyecta automáticamente con el UUID del usuario autenticado. La segunda controla INSERT con WITH CHECK: si intentas insertar una nota con el user_id de otra persona, la base de datos rechaza el insert. La tercera hace lo mismo para DELETE.
2:33Ahora el cliente de Supabase para el browser. Creamos una función que retorna un cliente usando createBrowserClient del paquete SSR. También exportamos el tipo Note que coincide exactamente con las columnas de nuestra tabla. Este cliente lee y escribe cookies automáticamente para persistir la sesión del usuario.
2:53El cliente de servidor es diferente. En el App Router, los Server Components necesitan leer las cookies del request para saber quién es el usuario. Por eso usamos createServerClient con un adaptador de cookies. El try-catch en setAll es importante: en Server Components no puedes escribir cookies directamente, eso lo maneja el middleware. Si omites el try-catch, tu app va a explotar.
3:20Ahora la API Route. El GET es donde se ve claramente el poder de RLS. Mira la query: select de notes, order por created_at. Sin ningún punto eq de user_id, sin ningún filtro manual. RLS aplica automáticamente el WHERE user_id igual a auth.uid() antes de que los datos salgan de la base de datos. El servidor nunca ve las notas de otros usuarios.
3:47El POST es igualmente interesante. Primero verificamos que el usuario está autenticado, validamos el título, y hacemos el insert pasando el user_id del usuario. Cuando RLS ejecuta la política WITH CHECK, compara ese user_id con auth.uid(). Si alguien modifica la request para pasar un user_id falso, Postgres rechaza el insert con un error 42501. No hay forma de hacer trampa.
4:13La página de login usa magic link de Supabase Auth. Sin contraseñas, sin formularios complejos. El usuario pone su email, llamamos a signInWithOtp, y Supabase manda un link que al hacer clic autentica al usuario y lo redirige a la ruta de notas. Esta sesión es lo que RLS usa para saber quién está haciendo las queries.
4:38El render del formulario es Tailwind puro: fondo oscuro, formulario centrado, input de email y botón de magic link. Si ya se envió el email, mostramos una pantalla de confirmación. Simple, limpio, funcional.
4:52El dashboard de notas empieza con un useEffect que verifica si hay sesión activa. Si no hay usuario, redirige al login. Si hay usuario, guardamos el email para mostrarlo en la cabecera y llamamos a fetchNotes que hace un GET a nuestra API Route. Recuerda: esa API no filtra nada, RLS lo hace todo.
5:16Las funciones de crear y cerrar sesión son straightforward. handleCreate hace un POST con el título y contenido. Si la respuesta es exitosa, limpiamos el formulario y recargamos las notas. handleLogout llama a signOut y redirige al login. Todo el trabajo duro lo está haciendo RLS en el fondo.
5:37Y el render del dashboard. Cabecera con el email del usuario y botón de logout. Formulario para crear notas con input de título, textarea de contenido y botón de agregar. La lista de notas mapea cada una con título, contenido y fecha. Y abajo, ese bloque educativo en indigo que explica exactamente qué está pasando: SELECT sin WHERE, RLS agrega el filtro automáticamente.
6:05Veamos la app en acción. Esta es la pantalla de login. Ingresamos el email y hacemos clic en Entrar con Magic Link. Supabase manda el email al instante.
6:17Después de autenticarse via el magic link, el usuario llega al dashboard. Aquí creamos una nota, llenamos el título y el contenido, y presionamos agregar. La nota aparece inmediatamente. Noten que en ningún momento el código filtró por user_id. RLS lo hizo todo.
6:35Errores comunes. El primero y más grave: olvidar ejecutar ENABLE ROW LEVEL SECURITY. La tabla existe, las políticas existen, pero RLS está apagado y cualquiera lee todo. El segundo: usar la service_role key en el cliente de browser. Esa key bypasea RLS completamente. Solo úsala en servidores de confianza. El tercero: activar RLS sin crear ninguna política. Con RLS activo y sin políticas, nadie puede leer nada, ni el propio usuario. Deny all por defecto.
7:08Pro tip que no está en la documentación oficial. Puedes testear tus políticas RLS directamente en el SQL Editor de Supabase sin necesitar autenticarte desde la app. Ejecuta SET LOCAL role = authenticated seguido de SET LOCAL con los claims del JWT simulando el sub del usuario. Luego corre tu query normal. Postgres ejecutará las políticas como si ese usuario estuviera autenticado. Esto es invaluable para depurar políticas complejas sin tocar el código.
7:40Para cerrar, lo que construimos hoy: una app de notas donde la seguridad vive en la base de datos. RLS se activa por tabla con ALTER TABLE ENABLE ROW LEVEL SECURITY y es deny all por defecto. auth.uid() retorna el UUID del usuario autenticado en la sesión actual. Las políticas FOR SELECT con USING filtran automáticamente, FOR INSERT usa WITH CHECK para validar. Un SELECT sin WHERE retorna solo las filas del usuario autenticado. Y la anon key es segura en el frontend porque RLS restringe los datos. El siguiente paso: agrega UPDATE con su política, o explora realtime con RLS para notas colaborativas donde defines exactamente quién puede suscribirse a qué cambios.