Seguridad en Supabase

La seguridad no es una feature que agregas al final. Si tu proyecto de Supabase está en producción sin las configuraciones de esta guía, tienes una brecha de seguridad activa. Punto.

Esta guía cubre todo lo que necesitas proteger: desde las API keys hasta los headers HTTP que tu servidor envía.

API Keys: anon key vs service_role key

Supabase genera dos API keys cuando creas un proyecto. Entender la diferencia entre ellas es la base de toda la seguridad de tu app.

anon key (pública)

La anon key es segura para incluir en el frontend. Toda operación que haga un cliente usando esta key pasa por RLS (Row Level Security). Si tus políticas están bien configuradas, la anon key no puede acceder a datos que no le corresponden.

typescript
// Esto es correcto y seguro en el cliente
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
 
// Esta query solo devuelve datos que las politicas RLS permiten
const { data } = await supabase.from('profiles').select('*');

Alguien puede ver tu anon key inspeccionando el JavaScript de tu sitio. Eso está bien -- es el diseño intencionado. La protección viene de RLS, no de esconder la key.

service_role key (privada)

La service_role key bypasea RLS por completo. Tiene acceso total a toda la base de datos. Es el equivalente a tener acceso root.

typescript
// SOLO en el servidor: Server Components, Server Actions, Route Handlers
import { createClient } from '@supabase/supabase-js';
 
const supabaseAdmin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);
 
// Esta query devuelve TODOS los datos, sin importar RLS
const { data } = await supabaseAdmin.from('profiles').select('*');
Si la service_role key se filtra, tu base de datos está comprometida

Un atacante con la service_role key puede leer, modificar y borrar toda tu base de datos. Puede crear usuarios admin, exportar datos de todos tus usuarios y borrar tablas completas. Trata esta key como una contraseña de base de datos.

Reglas para las API keys

Reglaanon keyservice_role key
Prefijo NEXT_PUBLIC_SiNunca
Usar en el clienteSiNunca
Usar en Server ComponentsSiSi
Usar en Server ActionsSiSi
Usar en Route HandlersSiSi
Depende de RLSSiNo (bypasea RLS)

Variables de entorno

Estructura correcta

plaintext
# .env.local
 
# Públicas (se incluyen en el bundle del cliente)
NEXT_PUBLIC_SUPABASE_URL=tu-url-aca
NEXT_PUBLIC_SUPABASE_ANON_KEY=tu-anon-key-aca
 
# Privadas (solo accesibles en el servidor)
SUPABASE_SERVICE_ROLE_KEY=tu-service-role-key-aca
SUPABASE_DB_URL=tu-connection-string-aca
NEXT_PUBLIC_ expone la variable al cliente

Cualquier variable que empiece con NEXT_PUBLIC_ se incluye en el JavaScript que llega al browser. Nunca pongas ese prefijo en variables sensibles como la service_role key, connection strings o secrets de terceros.

Verificar que no hay variables expuestas

Un error común es agregar el prefijo NEXT_PUBLIC_ a variables que deberían ser privadas, o hardcodear valores sensibles directamente en el código.

Para verificar que tu proyecto no tiene variables de entorno expuestas accidentalmente, puedes usar el Verificador de variables de entorno de datahogo. Analiza tu repositorio público y te avisa si detecta API keys, tokens o secrets que no deberían estar visibles.

También puedes hacer una verificación manual:

bash
# Buscar posibles secrets hardcodeados en tu codigo
grep -r "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" --include="*.ts" --include="*.tsx" .
grep -r "service_role" --include="*.ts" --include="*.tsx" . | grep -v ".env"
grep -r "SUPABASE_SERVICE_ROLE" --include="*.ts" --include="*.tsx" . | grep -v "process.env"

Si alguno de esos comandos devuelve resultados, tienes un problema.

Variables en Vercel

Cuando despliegues a Vercel, configura las variables en Settings > Environment Variables del proyecto. Nunca las comitees en tu repositorio.

bash
# Verificar que .env.local esta en .gitignore
cat .gitignore | grep ".env"

Si no ves .env.local o .env* en tu .gitignore, agrégalo antes de hacer cualquier commit.

Configuración de CORS

CORS (Cross-Origin Resource Sharing -- reglas que controlan que dominios pueden hacer requests a tu API) previene que sitios de terceros hagan requests a tu backend de Supabase como si fueran tu app.

En el dashboard de Supabase:

  1. Ve a Settings > API > CORS
  2. En Allowed origins, agrega solo los dominios de tu app:
plaintext
https://tudominio.com
https://www.tudominio.com
https://staging.tudominio.com
Nunca uses * en producción

El wildcard * permite requests desde cualquier dominio. Mientras desarrollas está bien, pero en producción solo deberías tener tus dominios específicos.

Restricciones de red

Supabase permite restringir el acceso a la base de datos por IP. Esto es especialmente útil si accedes directamente a PostgreSQL desde un servidor con IP fija.

  1. Ve a Settings > Database > Network Restrictions
  2. Agrega las IPs que deberían tener acceso directo a la base de datos
  3. Bloquea todo lo demás
plaintext
# Ejemplo: solo permitir acceso desde tu servidor de produccion
# y desde tu IP de desarrollo
Allowed IPs:
  - 203.0.113.50/32  (servidor de produccion)
  - 198.51.100.14/32 (tu IP de desarrollo)

Esto no afecta al SDK (que pasa por la API gateway de Supabase), pero si protege contra accesos directos no autorizados a PostgreSQL.

Prevención de SQL injection

El SDK de Supabase usa queries parametrizadas internamente, lo que previene SQL injection (inyección de código SQL malicioso) en operaciones normales:

typescript
// Seguro: el SDK parametriza los valores automáticamente
const { data } = await supabase
  .from('products')
  .select('*')
  .eq('category', userInput);
 
// Seguro: incluso con input malicioso, el SDK lo escapa
const maliciousInput = "'; DROP TABLE products; --";
const { data: safe } = await supabase
  .from('products')
  .select('*')
  .eq('category', maliciousInput);
// Se busca literalmente el string, no se ejecuta como SQL

Donde sí tienes riesgo

El peligro aparece cuando usas queries SQL crudas, ya sea con rpc() o conectándote directamente a PostgreSQL:

sql
-- MAL: concatenar input del usuario directamente
CREATE FUNCTION search_products(search_term text)
RETURNS SETOF products AS $$
  SELECT * FROM products WHERE name LIKE '%' || search_term || '%';
$$ LANGUAGE sql;
sql
-- BIEN: usar parametros o funciones seguras de PostgreSQL
CREATE FUNCTION search_products(search_term text)
RETURNS SETOF products AS $$
  SELECT * FROM products
  WHERE name ILIKE '%' || regexp_replace(search_term, '[%_\\]', '\\\0', 'g') || '%';
$$ LANGUAGE sql SECURITY DEFINER;
SECURITY DEFINER vs SECURITY INVOKER

Las funciones con SECURITY DEFINER se ejecutan con los permisos del creador (normalmente superuser). Usa esto con cuidado y solo cuando la función necesita acceso elevado. Para funciones normales, usa SECURITY INVOKER (por defecto) que respeta los permisos del usuario que la llama.

Seguridad de autenticación

Expiración de sesiones

Configura tiempos de expiración razonables para los JWT (JSON Web Tokens -- tokens de sesión que Supabase usa para identificar usuarios):

En el dashboard: Settings > Auth > JWT Settings

plaintext
JWT expiry: 3600  (1 hora - recomendado para apps con datos sensibles)

El SDK de Supabase maneja la renovación automática del token usando un refresh token. Cuando el JWT expira, el SDK solicita uno nuevo sin que el usuario tenga que volver a loguearse.

Políticas de contraseña

Configura requisitos mínimos en Settings > Auth > Password:

  • Longitud mínima: 8 caracteres (12 es mejor)
  • Requerir mayúsculas, números y símbolos si manejas datos sensibles
typescript
// Validacion adicional en el frontend antes de enviar a Supabase
function validatePassword(password: string): string | null {
  if (password.length < 12) return 'La contraseña debe tener al menos 12 caracteres';
  if (!/[A-Z]/.test(password)) return 'Debe incluir al menos una mayúscula';
  if (!/[0-9]/.test(password)) return 'Debe incluir al menos un número';
  if (!/[^A-Za-z0-9]/.test(password)) return 'Debe incluir al menos un símbolo';
  return null;
}

Proteger rutas en el servidor

No confíes solo en el middleware para proteger rutas. Valida la sesión en cada Server Component o Server Action que acceda a datos protegidos:

typescript
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
 
async function getSupabaseUser() {
  const cookieStore = await cookies();
 
  const supabase = 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);
          });
        },
      },
    }
  );
 
  const { data: { user }, error } = await supabase.auth.getUser();
 
  if (error || !user) {
    redirect('/login');
  }
 
  return { supabase, user };
}
getUser() valida contra el servidor, getSession() no

getSession() lee el JWT del cookie local sin verificarlo. Un atacante puede modificar ese cookie. Siempre usa getUser() para validar la sesión contra el servidor de Supabase antes de mostrar datos protegidos.

Headers de seguridad

Los headers HTTP que tu servidor envía son una capa adicional de protección. Configuralos en tu next.config.ts:

typescript
// next.config.ts
const nextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'X-Frame-Options',
            value: 'DENY',
          },
          {
            key: 'X-Content-Type-Options',
            value: 'nosniff',
          },
          {
            key: 'Referrer-Policy',
            value: 'strict-origin-when-cross-origin',
          },
          {
            key: 'Permissions-Policy',
            value: 'camera=(), microphone=(), geolocation=()',
          },
          {
            key: 'Strict-Transport-Security',
            value: 'max-age=63072000; includeSubDomains; preload',
          },
          {
            key: 'Content-Security-Policy',
            value: [
              "default-src 'self'",
              "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
              `connect-src 'self' ${process.env.NEXT_PUBLIC_SUPABASE_URL} wss://*.supabase.co`,
              "img-src 'self' data: blob:",
              "style-src 'self' 'unsafe-inline'",
              "font-src 'self'",
              "frame-ancestors 'none'",
            ].join('; '),
          },
        ],
      },
    ];
  },
};
 
export default nextConfig;

Cada header cumple una función específica:

HeaderQué hace
X-Frame-Options: DENYImpide que tu sitio se cargue dentro de un iframe (previene clickjacking)
X-Content-Type-Options: nosniffImpide que el browser interprete archivos con un MIME type incorrecto
Referrer-PolicyControla qué información de la URL se envía en el header Referer
Permissions-PolicyDeshabilita APIs del browser que no necesitas (cámara, micrófono, etc.)
Strict-Transport-SecurityFuerza HTTPS en todas las conexiones
Content-Security-PolicyControla de donde puede cargar recursos tu página
Verifica tus headers con datahogo

Después de configurar los headers, verifica que están llegando correctamente al browser. El Verificador de headers de seguridad de datahogo analiza tu dominio y te muestra cuáles headers faltan, cuáles están mal configurados y que vulnerabilidades quedan expuestas. Es gratis y no necesitas crear una cuenta.

RLS como capa principal de defensa

Ya cubrimos RLS en el checklist de producción, pero vale la pena profundizar en los patrones de seguridad avanzados.

Políticas basadas en roles

Si tu app tiene roles (admin, editor, viewer), puedes usar una tabla de roles y referenciarla en tus políticas:

sql
-- Tabla de roles
CREATE TABLE public.user_roles (
  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
  role text NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')),
  UNIQUE(user_id, role)
);
 
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
 
-- Solo admins pueden ver los roles
CREATE POLICY "Admins ven roles"
  ON public.user_roles
  FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM public.user_roles
      WHERE user_id = auth.uid()
        AND role = 'admin'
    )
  );
 
-- Política en otra tabla que usa roles
CREATE POLICY "Solo admins y editores pueden crear contenido"
  ON public.posts
  FOR INSERT
  WITH CHECK (
    EXISTS (
      SELECT 1 FROM public.user_roles
      WHERE user_id = auth.uid()
        AND role IN ('admin', 'editor')
    )
  );

Para verificar que tu configuración de RLS no tiene huecos, puedes usar el Verificador de RLS de datahogo. Analiza tus políticas y detecta tablas sin protección, políticas demasiado permisivas y patrones que pueden generar vulnerabilidades.

Testear políticas de RLS

Antes de ir a producción, testea tus políticas simulando diferentes usuarios:

sql
-- Simular un usuario especifico para testear RLS
SET request.jwt.claim.sub = 'uuid-del-usuario-de-prueba';
SET request.jwt.claim.role = 'authenticated';
 
-- Ejecutar query como ese usuario
SELECT * FROM public.profiles;
 
-- Resetear
RESET request.jwt.claim.sub;
RESET request.jwt.claim.role;

Auditoría de vulnerabilidades

Una revisión manual está bien, pero no escala. A medida que tu app crece, necesitas herramientas que revisen automáticamente los vectores de ataque comunes.

El Audit OWASP de datahogo hace un escaneo automatizado basado en el OWASP Top 10 (las 10 vulnerabilidades web más comunes según la Open Web Application Security Project). Cubre inyección, autenticación rota, exposición de datos sensibles, configuración incorrecta de seguridad y más. Puedes ejecutarlo contra tu dominio de producción o staging.

Monitoreo y respuesta a incidentes

Qué monitorear

Configura alertas para detectar actividad sospechosa:

  • Intentos de login fallidos por encima de lo normal (posible fuerza bruta)
  • Queries inusuales desde la service_role key (posible key comprometida)
  • Picos de tráfico en endpoints específicos (posible ataque DDoS)
  • Errores 403/401 en la API (posible intento de acceso no autorizado)

Logging

Supabase registra queries y errores en Database > Logs. Configura la retención de logs según tus necesidades regulatorias.

Para logging adicional en tu app:

typescript
// lib/logger.ts
type LogLevel = 'info' | 'warn' | 'error';
 
interface LogEntry {
  level: LogLevel;
  message: string;
  context?: Record<string, unknown>;
  timestamp: string;
}
 
function log(level: LogLevel, message: string, context?: Record<string, unknown>) {
  const entry: LogEntry = {
    level,
    message,
    context,
    timestamp: new Date().toISOString(),
  };
 
  // En produccion, envia a tu servicio de logging
  if (process.env.NODE_ENV === 'production') {
    // Sentry, LogRocket, Datadog, etc.
    console.log(JSON.stringify(entry));
  } else {
    console.log(`[${level.toUpperCase()}] ${message}`, context);
  }
}
 
export const logger = {
  info: (msg: string, ctx?: Record<string, unknown>) => log('info', msg, ctx),
  warn: (msg: string, ctx?: Record<string, unknown>) => log('warn', msg, ctx),
  error: (msg: string, ctx?: Record<string, unknown>) => log('error', msg, ctx),
};
typescript
// Uso en un Server Action
import { logger } from '@/lib/logger';
 
export async function deleteAccountAction() {
  'use server';
 
  const { supabase, user } = await getSupabaseUser();
 
  logger.info('Solicitud de eliminación de cuenta', { userId: user.id });
 
  const { error } = await supabase.from('profiles').delete().eq('user_id', user.id);
 
  if (error) {
    logger.error('Error eliminando cuenta', { userId: user.id, error: error.message });
    throw new Error('No se pudo eliminar la cuenta');
  }
 
  logger.info('Cuenta eliminada exitosamente', { userId: user.id });
}

Plan de respuesta a incidentes

Ten un plan antes de que lo necesites:

  1. Detectar: las alertas configuradas te avisan
  2. Contener: si una API key se compromete, rota las keys inmediatamente desde el dashboard (Settings > API > Regenerate keys)
  3. Investigar: revisa los logs para entender el alcance del incidente
  4. Remediar: corrige la vulnerabilidad que causó el incidente
  5. Comunicar: si hubo exposición de datos de usuarios, notifícalos
sql
-- Rotar la anon key fuerza a todos los clientes a actualizarse
-- Rotar la service_role key invalida cualquier acceso backend con la key vieja
 
-- Revocar todas las sesiones de un usuario comprometido
SELECT auth.revoke_all_sessions('uuid-del-usuario');
Documenta tu plan de respuesta

No esperes a tener un incidente para decidir qué hacer. Documenta quién es responsable de cada paso, cómo contactar al equipo fuera de horario y dónde están los accesos al dashboard de Supabase. Un plan de respuesta que nadie conoce no sirve.

Resumen de buenas prácticas

ÁreaAcción
API keysanon key en el cliente, service_role solo en el servidor
Variables de entornoNunca NEXT_PUBLIC_ en secrets, nunca hardcodear valores
CORSSolo tus dominios, nunca * en producción
RedRestringir IPs de acceso directo a PostgreSQL
SQL injectionUsar el SDK, evitar queries crudas con input del usuario
AuthgetUser() para validar sesiones, contraseñas fuertes, JWT con expiración
HeadersCSP, HSTS, X-Frame-Options y el resto configurados
RLSActivado en todas las tablas, políticas testeadas
MonitoreoAlertas para login fallidos, picos de tráfico, errores de auth
IncidentesPlan documentado, saber cómo rotar keys