Para agentes de IA / vibe coding: Este archivo es tu contrato de seguridad. Antes de generar cualquier código que toque autenticación, base de datos, storage o variables de entorno, lee la sección correspondiente. No asumas que algo es seguro porque "funciona".
- Flujo de la Arquitectura
- Variables de Entorno y Secretos
- Autenticación y Sesiones
- Row Level Security (RLS)
- Validación y Sanitización de Inputs
- API Keys y Permisos
- Storage y Archivos
- Edge Functions y Backend
- OWASP Top 10 — Aplicado a Supabase
- Checklist Pre-Deploy
- Qué NUNCA hacer
Entender este flujo es crítico antes de escribir cualquier línea de código.
┌─────────────────────────────────────────────────────────────┐
│ CLIENTE (Browser / App) │
│ │
│ - Solo usa SUPABASE_URL y ANON_KEY (pública) │
│ - Nunca tiene acceso a SERVICE_ROLE_KEY │
│ - Toda acción pasa por RLS en la base de datos │
└──────────────────────────┬──────────────────────────────────┘
│ HTTPS
▼
┌─────────────────────────────────────────────────────────────┐
│ SUPABASE (BaaS) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Auth Server │ │ PostgREST │ │ Storage │ │
│ │ (JWT) │ │ (API→DB) │ │ (Buckets) │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ │ │
│ ┌──────▼───────┐ │
│ │ PostgreSQL │ ← RLS aplica AQUÍ │
│ │ + Policies │ │
│ └──────────────┘ │
└──────────────────────────┬──────────────────────────────────┘
│ Solo desde servidor
▼
┌─────────────────────────────────────────────────────────────┐
│ TU BACKEND / EDGE FUNCTIONS │
│ │
│ - Usa SERVICE_ROLE_KEY solo aquí (nunca en cliente) │
│ - Valida tokens JWT antes de procesar │
│ - Lógica de negocio sensible va aquí │
└─────────────────────────────────────────────────────────────┘
Regla de oro: Si el código se ejecuta en el cliente (React, Vue, Next.js sin SSR, etc.), asume que el usuario puede ver TODO lo que está en ese código.
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Estas son públicas por diseño. La seguridad real la da RLS, no ocultar estas claves.
SUPABASE_SERVICE_ROLE_KEY=eyJ... # NUNCA en el cliente
SUPABASE_JWT_SECRET=tu-secreto # NUNCA en el cliente
OPENAI_API_KEY=sk-... # NUNCA en el cliente
STRIPE_SECRET_KEY=sk_live_... # NUNCA en el cliente- Nunca generes código que pase
SERVICE_ROLE_KEYa un componente de React, Vue, o cualquier archivo que se sirva al browser. - Si necesitas hacer una operación con permisos elevados, créala en una Edge Function o un API Route del servidor.
- Verifica que
.env.local,.env.productionestén en.gitignore. - Nunca hardcodees secrets en el código fuente, ni como "placeholder temporal".
.env
.env.local
.env.*.local
.env.production
.env.development
*.pem
*.key// lib/supabase/client.ts — Para uso en el BROWSER
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}// lib/supabase/server.ts — Para uso en SERVER COMPONENTS / API ROUTES
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) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
}// Siempre verifica la sesión en el servidor, no confíes solo en el cliente
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) {
redirect('/login') // o return 401
}
// A partir de aquí, user está autenticado- Nunca uses
getSession()en el servidor para verificar permisos — usagetUser()(valida contra el servidor de auth). - Implementa refresh automático de tokens con el middleware de Supabase SSR.
- Usa magic links o OAuth cuando sea posible; si usas contraseñas, delega el hash a Supabase (nunca hagas hash manual).
- Implementa rate limiting en los endpoints de login (Supabase lo tiene por defecto, no lo desactives).
- Para roles de admin, usa custom claims en JWT o una tabla
user_rolescon RLS.
Regla absoluta: Toda tabla que contenga datos de usuarios DEBE tener RLS activado.
-- Paso 1: Activar RLS en la tabla
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Paso 2: Política de lectura — solo el propio usuario
CREATE POLICY "Users can view own profile"
ON profiles FOR SELECT
USING (auth.uid() = user_id);
-- Paso 3: Política de escritura — solo el propio usuario puede editar su perfil
CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Paso 4: Política de inserción
CREATE POLICY "Users can insert own profile"
ON profiles FOR INSERT
WITH CHECK (auth.uid() = user_id);-- Lectura pública, escritura solo del autor
CREATE POLICY "Public can read posts"
ON posts FOR SELECT
USING (published = true);
CREATE POLICY "Authors can manage own posts"
ON posts FOR ALL
USING (auth.uid() = author_id)
WITH CHECK (auth.uid() = author_id);-- Función helper para verificar rol
CREATE OR REPLACE FUNCTION is_admin()
RETURNS boolean AS $$
SELECT EXISTS (
SELECT 1 FROM user_roles
WHERE user_id = auth.uid() AND role = 'admin'
);
$$ LANGUAGE sql SECURITY DEFINER;
-- Policy usando el helper
CREATE POLICY "Admins can do everything"
ON sensitive_table FOR ALL
USING (is_admin());- Cada vez que generes una migración o tabla nueva, incluye automáticamente
ALTER TABLE x ENABLE ROW LEVEL SECURITY. - Nunca generes código que llame a
supabase.from('tabla')desde el cliente sin confirmar que existe una RLS policy. - Si una operación requiere
SERVICE_ROLE_KEYpara saltarse RLS, eso es una señal de que la arquitectura necesita revisarse.
// ✅ Usar Zod para validación robusta
import { z } from 'zod'
const CreatePostSchema = z.object({
title: z.string().min(1).max(200).trim(),
content: z.string().min(1).max(10000),
tags: z.array(z.string().max(50)).max(10).optional(),
published: z.boolean().default(false),
})
// En tu API Route o Edge Function:
export async function POST(req: Request) {
const body = await req.json()
const result = CreatePostSchema.safeParse(body)
if (!result.success) {
return Response.json({ error: result.error.flatten() }, { status: 400 })
}
const { title, content, tags, published } = result.data
// Ahora es seguro usar estos valores
}Supabase PostgREST usa queries parametrizadas por defecto. Nunca construyas queries con concatenación de strings:
// 🚫 NUNCA hagas esto
const { data } = await supabase.rpc('raw_query', {
sql: `SELECT * FROM users WHERE name = '${userInput}'`
})
// ✅ Usa siempre los métodos del cliente
const { data } = await supabase
.from('users')
.select('*')
.eq('name', userInput) // Parametrizado automáticamenteimport DOMPurify from 'dompurify'
// Al renderizar contenido generado por usuarios
const safeHTML = DOMPurify.sanitize(userContent, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
ALLOWED_ATTR: [],
})| Clave | Dónde va | Qué puede hacer |
|---|---|---|
anon key |
Cliente (browser) | Lo que RLS permita + auth pública |
service_role key |
Solo servidor | Bypass de RLS — peligro máximo |
| JWT Secret | Solo servidor | Firmar/verificar tokens |
// Edge Function o API Route — NUNCA en el browser
import { createClient } from '@supabase/supabase-js'
const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // Solo aquí
{
auth: {
autoRefreshToken: false,
persistSession: false,
}
}
)// Verifica siempre la firma de webhooks entrantes
async function verifyWebhook(req: Request, secret: string): Promise<boolean> {
const signature = req.headers.get('x-webhook-signature')
const body = await req.text()
const hmac = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const expected = await crypto.subtle.sign(
'HMAC',
hmac,
new TextEncoder().encode(body)
)
const expectedHex = Buffer.from(expected).toString('hex')
return signature === expectedHex
}-- Bucket privado (por defecto, recomendado para datos de usuarios)
INSERT INTO storage.buckets (id, name, public)
VALUES ('user-uploads', 'user-uploads', false);
-- Policy: solo el dueño puede ver sus archivos
CREATE POLICY "Users can view own files"
ON storage.objects FOR SELECT
USING (auth.uid()::text = (storage.foldername(name))[1]);
CREATE POLICY "Users can upload own files"
ON storage.objects FOR INSERT
WITH CHECK (auth.uid()::text = (storage.foldername(name))[1]);
CREATE POLICY "Users can delete own files"
ON storage.objects FOR DELETE
USING (auth.uid()::text = (storage.foldername(name))[1]);const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
const MAX_SIZE_MB = 5
function validateFile(file: File): { valid: boolean; error?: string } {
if (!ALLOWED_TYPES.includes(file.type)) {
return { valid: false, error: 'Tipo de archivo no permitido' }
}
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
return { valid: false, error: `El archivo no puede superar ${MAX_SIZE_MB}MB` }
}
return { valid: true }
}
// Al subir
async function uploadFile(file: File, userId: string) {
const { valid, error } = validateFile(file)
if (!valid) throw new Error(error)
// Sanitiza el nombre del archivo
const safeName = file.name.replace(/[^a-zA-Z0-9.\-_]/g, '_')
const path = `${userId}/${Date.now()}-${safeName}`
const { data, error: uploadError } = await supabase.storage
.from('user-uploads')
.upload(path, file)
if (uploadError) throw uploadError
return data
}// Nunca expongas la ruta directa de archivos privados
// Usa URLs firmadas con expiración
const { data } = await supabase.storage
.from('user-uploads')
.createSignedUrl(filePath, 3600) // Expira en 1 hora
return data?.signedUrl// supabase/functions/mi-funcion/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const corsHeaders = {
'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN ?? '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
serve(async (req) => {
// Handle preflight
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
// 1. Verificar autenticación
const authHeader = req.headers.get('Authorization')
if (!authHeader) {
return new Response(JSON.stringify({ error: 'No autorizado' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
// 2. Crear cliente con el JWT del usuario (respeta RLS)
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: authHeader } } }
)
// 3. Verificar usuario
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Token inválido' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
// 4. Validar input
const body = await req.json()
// ... tu validación aquí
// 5. Lógica de negocio
// ...
return new Response(JSON.stringify({ success: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} catch (error) {
// 6. Nunca expongas detalles del error interno
console.error('Error interno:', error)
return new Response(JSON.stringify({ error: 'Error interno del servidor' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
})// ❌ En producción, no uses '*' si tu app maneja datos sensibles
'Access-Control-Allow-Origin': '*'
// ✅ Lista blanca de orígenes permitidos
const ALLOWED_ORIGINS = [
'https://tuapp.com',
'https://www.tuapp.com',
]
const origin = req.headers.get('Origin') ?? ''
const allowedOrigin = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]Riesgo: Usuarios acceden a datos de otros usuarios. Mitigación:
- RLS activado en TODAS las tablas (
ALTER TABLE x ENABLE ROW LEVEL SECURITY) - Verificar
auth.uid()en cada policy - Nunca confiar en IDs enviados desde el cliente para determinar ownership
Riesgo: Datos sensibles expuestos en tránsito o en reposo. Mitigación:
- Supabase usa HTTPS/TLS por defecto — no lo desactives
- Nunca loguees datos sensibles (contraseñas, tokens, PII)
- Usa variables de entorno para todos los secrets
- Columnas con datos sensibles (ej. PAN de tarjetas) → encriptar con
pgcrypto
Riesgo: SQL injection, XSS. Mitigación:
- Usa siempre el cliente de Supabase (queries parametrizadas automáticamente)
- Nunca construyas SQL con concatenación de strings
- Sanitiza HTML con DOMPurify antes de renderizar
- Valida y tipifica todos los inputs con Zod o similar
Riesgo: Flujos de negocio explotables. Mitigación:
- Lógica crítica (pagos, roles, permisos especiales) siempre en el servidor
- No confíes en datos enviados por el cliente para calcular precios o permisos
- Implementa rate limiting en endpoints de auth y acciones críticas
Riesgo: Configuración por defecto insegura. Mitigación:
- Desactivar el acceso público a buckets de storage (a menos que sea intencional)
- Revisar las políticas de auth en el dashboard de Supabase
- Activar autenticación de dos factores en tu cuenta de Supabase
- Revisar que
service_rolekey no esté expuesta
Riesgo: Dependencias desactualizadas con vulnerabilidades conocidas. Mitigación:
- Ejecutar
npm auditregularmente - Mantener
@supabase/supabase-jsactualizado - Usar
dependabotorenovatepara actualizaciones automáticas
Riesgo: Sesiones robadas, fuerza bruta. Mitigación:
- Usar
getUser()(nogetSession()) para validar en el servidor - No deshabilitar el rate limiting de Supabase Auth
- Implementar logout en todos los dispositivos cuando se detecte actividad sospechosa
- Usar PKCE flow para OAuth
Riesgo: Código o datos comprometidos en el pipeline. Mitigación:
- Verificar firmas en webhooks entrantes (Stripe, GitHub, etc.)
- Usar Supabase Database Webhooks con validación
- Lockfile (
package-lock.json/yarn.lock) en el repositorio
Riesgo: Ataques no detectados. Mitigación:
- Activar los logs de Supabase en el dashboard
- Loguear intentos de acceso fallidos (sin datos sensibles)
- Configurar alertas para queries inusuales o errores 401/403 masivos
- Nunca loguees: passwords, tokens, datos de tarjetas, PII completa
// ✅ Log seguro
console.log('Login fallido', { userId: user?.id, timestamp: new Date().toISOString() })
// 🚫 Log inseguro
console.log('Login fallido', { email, password, token }) // NUNCARiesgo: Tu servidor hace requests a URLs controladas por el atacante. Mitigación:
- Si tu Edge Function acepta URLs como input, valida contra una lista blanca
- No hagas
fetch(userProvidedUrl)directamente
const ALLOWED_DOMAINS = ['api.tuservicio.com', 'hooks.slack.com']
function isSafeUrl(url: string): boolean {
try {
const { hostname } = new URL(url)
return ALLOWED_DOMAINS.some(d => hostname === d || hostname.endsWith(`.${d}`))
} catch {
return false
}
}Ejecuta esta lista antes de cada deploy a producción:
- RLS activado en todas las tablas con datos de usuarios
- Cada tabla tiene policies para SELECT, INSERT, UPDATE, DELETE según necesidad
- No hay tablas con
SECURITY DEFINERfunciones que puedan ser explotadas - Las migraciones no exponen datos de un usuario a otro
-
SERVICE_ROLE_KEYno está en ningún archivo del cliente -
.envy variantes están en.gitignore - Las variables de entorno de producción están configuradas en el servidor/CI
- El middleware de Supabase SSR está configurado para refresh de tokens
- Las rutas protegidas verifican la sesión en el servidor
- No hay rutas que acepten
userIddesde el cliente como parámetro de trust
- Los buckets privados tienen
public: false - Las policies de storage restringen acceso por
auth.uid() - Se valida tipo y tamaño de archivo antes de subir
- Todos los endpoints verifican autenticación antes de procesar
- Los inputs se validan con schema (Zod, Joi, Yup, etc.)
- Los errores no exponen stack traces ni detalles internos
- CORS configurado con lista blanca (no
*en producción con datos sensibles)
-
npm auditsin vulnerabilidades críticas o altas -
@supabase/supabase-jsen versión estable reciente
- Logs de Supabase activados
- Se loguean eventos de seguridad (sin datos sensibles)
Lista rápida para el agente de IA y desarrolladores. Si algo de esta lista aparece en el código, es un bug de seguridad crítico:
🚫 NUNCA pongas SERVICE_ROLE_KEY en código de cliente
🚫 NUNCA construyas SQL concatenando strings con input del usuario
🚫 NUNCA confíes en user_id enviado en el body del request — usa auth.uid()
🚫 NUNCA desactives RLS en tablas de producción para "simplificar"
🚫 NUNCA expongas stack traces o mensajes de error de DB al cliente
🚫 NUNCA loguees passwords, tokens o datos de tarjetas
🚫 NUNCA hagas fetch a URLs proporcionadas por el usuario sin validación
🚫 NUNCA almacenes secrets en el código fuente (ni como "temporal")
🚫 NUNCA renderices HTML de usuarios sin sanitizar con DOMPurify
🚫 NUNCA uses getSession() para verificar permisos en el servidor
🚫 NUNCA hagas buckets de storage públicos con datos sensibles de usuarios
🚫 NUNCA permitas subida de archivos sin validar tipo y tamaño
- Supabase Security Docs
- OWASP Top 10 (2021)
- Supabase Auth SSR
- Supabase Storage Policies
- OWASP Cheat Sheet — SQL Injection Prevention
Este archivo debe mantenerse actualizado con cada cambio significativo en la arquitectura del proyecto. Versión mínima requerida: revisión en cada sprint o antes de cada release.