Skip to content

Instantly share code, notes, and snippets.

@JkDevArg
Last active May 5, 2026 10:04
Show Gist options
  • Select an option

  • Save JkDevArg/e13fc0573e703bd537294bda72284d43 to your computer and use it in GitHub Desktop.

Select an option

Save JkDevArg/e13fc0573e703bd537294bda72284d43 to your computer and use it in GitHub Desktop.
SECURITY.MD

🔐 SECURITY.md — Guía de Seguridad para Proyectos con Supabase

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".


📋 Índice

  1. Flujo de la Arquitectura
  2. Variables de Entorno y Secretos
  3. Autenticación y Sesiones
  4. Row Level Security (RLS)
  5. Validación y Sanitización de Inputs
  6. API Keys y Permisos
  7. Storage y Archivos
  8. Edge Functions y Backend
  9. OWASP Top 10 — Aplicado a Supabase
  10. Checklist Pre-Deploy
  11. Qué NUNCA hacer

1. Flujo de la Arquitectura

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.


2. Variables de Entorno y Secretos

✅ Variables permitidas en el cliente (prefijo NEXT_PUBLIC_ / VITE_ / PUBLIC_)

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.

🚫 Variables SOLO para servidor / Edge Functions

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

Reglas para el agente de IA

  • Nunca generes código que pase SERVICE_ROLE_KEY a 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.production estén en .gitignore.
  • Nunca hardcodees secrets en el código fuente, ni como "placeholder temporal".

.gitignore mínimo requerido

.env
.env.local
.env.*.local
.env.production
.env.development
*.pem
*.key

3. Autenticación y Sesiones

Inicialización correcta del cliente

// 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)
          )
        },
      },
    }
  )
}

Verificación de sesión en rutas protegidas

// 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

Reglas de autenticación

  • Nunca uses getSession() en el servidor para verificar permisos — usa getUser() (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_roles con RLS.

4. Row Level Security (RLS)

RLS es tu primera y más importante línea de defensa

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);

Patrón para recursos compartidos (ej. posts públicos)

-- 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);

Patrón para roles (admin)

-- 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());

Para el agente de IA

  • 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_KEY para saltarse RLS, eso es una señal de que la arquitectura necesita revisarse.

5. Validación y Sanitización de Inputs

Valida SIEMPRE en el servidor, nunca confíes solo en el cliente

// ✅ 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
}

Prevención de SQL Injection

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áticamente

Sanitización de HTML (si renderizas contenido de usuarios)

import DOMPurify from 'dompurify'

// Al renderizar contenido generado por usuarios
const safeHTML = DOMPurify.sanitize(userContent, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
  ALLOWED_ATTR: [],
})

6. API Keys y Permisos

Jerarquía de claves Supabase

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

Uso seguro del service role (solo en servidor)

// 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,
    }
  }
)

Tokens de terceros y webhooks

// 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
}

7. Storage y Archivos

Configuración de buckets

-- 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]);

Validación de archivos antes de subir

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
}

URLs firmadas para archivos privados

// 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

8. Edge Functions y Backend

Estructura segura de una Edge Function

// 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' },
    })
  }
})

CORS — Configuración correcta

// ❌ 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]

9. OWASP Top 10 — Aplicado a Supabase

A01 — Broken Access Control

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

A02 — Cryptographic Failures

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

A03 — Injection

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

A04 — Insecure Design

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

A05 — Security Misconfiguration

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_role key no esté expuesta

A06 — Vulnerable Components

Riesgo: Dependencias desactualizadas con vulnerabilidades conocidas. Mitigación:

  • Ejecutar npm audit regularmente
  • Mantener @supabase/supabase-js actualizado
  • Usar dependabot o renovate para actualizaciones automáticas

A07 — Authentication Failures

Riesgo: Sesiones robadas, fuerza bruta. Mitigación:

  • Usar getUser() (no getSession()) 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

A08 — Software and Data Integrity Failures

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

A09 — Logging & Monitoring Failures

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 }) // NUNCA

A10 — Server-Side Request Forgery (SSRF)

Riesgo: 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
  }
}

10. Checklist Pre-Deploy

Ejecuta esta lista antes de cada deploy a producción:

Base de datos

  • 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 DEFINER funciones que puedan ser explotadas
  • Las migraciones no exponen datos de un usuario a otro

Variables de entorno

  • SERVICE_ROLE_KEY no está en ningún archivo del cliente
  • .env y variantes están en .gitignore
  • Las variables de entorno de producción están configuradas en el servidor/CI

Autenticación

  • 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 userId desde el cliente como parámetro de trust

Storage

  • 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

API / Edge Functions

  • 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)

Dependencias

  • npm audit sin vulnerabilidades críticas o altas
  • @supabase/supabase-js en versión estable reciente

Monitoreo

  • Logs de Supabase activados
  • Se loguean eventos de seguridad (sin datos sensibles)

11. Qué NUNCA hacer

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

Referencias


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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment