Auth Flow Design en Supabase: El Patrón de Validación Jerárquica que Previene el 90% de Brechas de Seguridad
Implementa auth segura en Supabase con OAuth, magic links y JWTs. El framework de validación en 3 capas que el 90% ignora.
El 90% de los Desarrolladores Implementa Auth en Supabase Como Si El Navegador Fuera Confiable
Vuestra auth de Supabase tiene un problema que nadie menciona.
El JWT que Supabase sirve al cliente es un token legible. Cualquiera puede abrir devtools, copiar el payload, y modificarlo. No es теория — es la realidad del protocolo.
*El problema real no es que Supabase sea inseguro. Es que tratáis RLS como vuestra única línea de defensa mientras ignoráis que el cliente es un entorno hostil.*
La mayoría implementa auth así: configuran OAuth o magic links, añaden una policy RLS, y se olvidan. Confían ciegamente en que Row Level Security protege todo.
No lo hace.
Por Qué RLS No Es Suficiente Para Proteger Vuestra API
Supabase Auth sirve JWTs client-side mediante gotrue-js. Esto significa que el token llega al navegador en texto plano — base64-encoded, no cifrado.
Cualquiera puede decodificarlo.
Abrid devtools → Application → Cookies o localStorage → cogéis el access_token → lo pegáis en jwt.io. Veréis el payload completo: user_id, role, app_metadata con vuestras custom claims.
Ahora imaginad esto: un usuario logueado inspecta su token, cambia el valor de subscription_tier de "free" a "pro", reenvía la petición. RLS evalúa contra la base de datos, cierto. Pero si vuestra policy проверяет el rol del JWT directamente...
```sql
-- Policy INSEGURA: confía ciegamente en el JWT
CREATE POLICY "Acceso contenido pro" ON contenido
FOR SELECT
USING (
auth.jwt() -> 'app_metadata' ->> 'subscription_tier' = 'pro'
);
```
El usuario acaba de escalar privilegios desde el navegador. La policy se ejecuta server-side, sí. Pero el valor viene del JWT — que el cliente controla.
❌ Evitad: Policies que leen directamente del JWT sin validación server-side.
❌ Evitad: Confiar en que el token no ha sido manipulado.
Cómo Supabase Maneja JWTs: La Realidad Que No Queréis Escuchar
El flujo estándar de Supabase Auth funciona así:
1. Usuario se autentica (OAuth, magic link, password)
2. Supabase genera un JWT con claims básicos: sub (user_id), role, exp, iat
3. gotrue-js almacena el token en localStorage o sessionStorage
4. Cada request incluye el token como Authorization header
5. Supabase valida firma, expira, y aplica RLS policies
El token contiene app_metadata donde se inyectan custom claims. Estos se actualizan cuando refreshéis el token o cuando modificáis los metadatos del usuario desde el dashboard o via API.
El payload es legible. La firma es verificable. La expiración es enforceable. Pero la lógica de authorization no puede depender ciegamente de lo que el cliente envía.
El Flujo OAuth en Supabase
OAuth en Supabase utiliza PKCE (Proof Key for Code Exchange) para authorization code flows. Esto significa:
```javascript
// Implementación correcta con PKCE
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: 'https://vuestra-app.com/callback',
scopes: 'read:user user:email'
}
});
// En el callback, PKCE ya está implementado internamente
// No necesitáis gestionar el code_verifier manualmente
```
PKCE mitiga ataques de interceptación del authorization code. Pero no protege contra manipulated tokens post-authentication.
El Flujo de Magic Links
Magic links omiten passwords: se envía un email con un enlace que contiene un token. El flujo:
```javascript
// Envío del magic link
await supabase.auth.signInWithOtp({
email: 'usuario@example.com',
options: {
emailRedirectTo: 'https://vuestra-app.com/confirmar'
}
});
// El enlace llega con un token en la URL
// Supabase intercambia este token por JWTs (access + refresh)
```
El problema no es el flujo. Es que una vez obtenido el JWT, no hay forma de distinguir si vino de magic link, OAuth, o password — todas producen tokens con la misma estructura.
El Framework de Validación Jerárquica: 3 Capas Para Auth Seguro
He diseñado un framework que transforma cómo implementáis auth en Supabase. Lo llamo el Patrón de Validación Jerárquica en 3 Capas.
Capa 1: Validación de Firmas y Claims Estructurales
Nunca confiéis en un JWT sin verificar su firma criptográficamente. Supabase lo hace por defecto, pero debéis verificar en Edge Functions:
```typescript
// Edge Function: middleware de validación JWT
import { verifyJWT } from 'micro-jwt-auth';
export const validateRequest = async (req: Request) => {
const authHeader = req.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response('Unauthorized', { status: 401 });
}
const token = authHeader.split(' ')[1];
try {
const decoded = await verifyJWT(token, Deno.env.get('SUPABASE_JWT_SECRET')!);
// Verificar estructura mínima
if (!decoded.sub || !decoded.exp || !decoded.iat) {
throw new Error('Invalid token structure');
}
return decoded;
} catch (err) {
return new Response('Invalid token', { status: 401 });
}
};
```
Capa 2: Custom Claims Via Database, No Via JWT
*El secreto que nadie explica*: las custom claims en el JWT son útiles para metadata, no para authorization decisions.
La arquitectura correcta:
```sql
-- Tabla de autorizaciones separada de los metadatos del JWT
CREATE TABLE user_authorization (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
subscription_tier TEXT NOT NULL DEFAULT 'free',
organization_id UUID,
organization_role TEXT NOT NULL DEFAULT 'member',
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Function para verificar autorización (NO desde el JWT)
CREATE OR REPLACE FUNCTION check_user_authorization(
p_user_id UUID,
p_required_tier TEXT[]
) RETURNS BOOLEAN AS $$
DECLARE
v_tier TEXT;
BEGIN
SELECT subscription_tier INTO v_tier
FROM user_authorization
WHERE user_id = p_user_id;
RETURN v_tier = ANY(p_required_tier);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Policy que usa la function, no el JWT directamente
CREATE POLICY "Contenido pro" ON contenido
FOR SELECT
USING (
check_user_authorization(auth.uid(), ARRAY['pro', 'enterprise'])
);
```
Capa 3: Re-validation Periódica en Edge Functions
No basta con verificar una vez. Las authorizations cambian: un usuario puede downgrade, perder acceso organization, o ser baneado. Si vuestro token tiene expires_in de 3600 segundos, estáis confiando en datos stale durante una hora entera.
```typescript
// Edge Function con re-validación de authorization
export const dynamicAuthorization = async (req: Request) => {
const decoded = await validateRequest(req);
const userId = decoded.sub;
// Re-validar authorization desde la base de datos, no desde el JWT
const { data: authData, error } = await supabase
.from('user_authorization')
.select('subscription_tier, organization_role')
.eq('user_id', userId)
.single();
if (error || !authData) {
return new Response('Authorization not found', { status: 403 });
}
// Ahora authData viene de la base de datos, no del JWT
const context = {
userId,
subscriptionTier: authData.subscription_tier,
organizationRole: authData.organization_role
};
// Continuar con la lógica de negocio...
};
```
Implementación Práctica: Auth Flow Completo con Custom Claims
Vamos a implementar un flujo completo que use OAuth con GitHub, magic links como fallback, y custom claims correctamente validados.
Paso 1: Configurar Supabase Auth con Providers
```javascript
// supabaseClient.js
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!;
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true
}
});
```
Paso 2: Función de Hook Post-Authentication
Supabase permite webhooks o database functions que ejecutan después de auth events:
```sql
-- Trigger que actualiza user_authorization post-signup
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO user_authorization (user_id, subscription_tier)
VALUES (NEW.id, 'free');
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION handle_new_user();
```
Paso 3: Middleware de Verificación en Edge Functions
```typescript
// middleware.ts - verificado y reutilizable
export const withAuth = async (
req: Request,
requiredTiers?: string[]
): Promise<{ user: any; auth: any; valid: boolean }> => {
const decoded = await validateRequest(req);
if (!decoded) {
return { user: null, auth: null, valid: false };
}
// Si se requieren tiers específicos, verificar en base de datos
if (requiredTiers && requiredTiers.length > 0) {
const { data } = await supabase
.from('user_authorization')
.select('subscription_tier')
.eq('user_id', decoded.sub)
.single();
if (!data || !requiredTiers.includes(data.subscription_tier)) {
return { user: decoded, auth: data, valid: false };
}
}
const { data: authData } = await supabase
.from('user_authorization')
.select('*')
.eq('user_id', decoded.sub)
.single();
return { user: decoded, auth: authData, valid: true };
};
```
Paso 4: Proteger Rutas con Policies Robustas
```sql
-- Policy para contenido premium: verifica subscription en la tabla, no en JWT
CREATE POLICY "Acceso premium" ON contenido_premium
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM user_authorization
WHERE user_id = auth.uid()
AND subscription_tier IN ('pro', 'enterprise')
)
);
-- Policy para recursos organization: verifica membership en la tabla
CREATE POLICY "Acceso organization" ON org_resources
FOR ALL
USING (
EXISTS (
SELECT 1 FROM user_authorization
WHERE user_id = auth.uid()
AND organization_id = org_resources.organization_id
AND organization_role IN ('admin', 'editor')
)
);
```
OAuth vs Magic Links: Cuándo Usar Cada Uno
OAuth (GitHub, Google, etc.)
✅ Usad OAuth cuando:
Queréis reducir fricción de registro
Necesitáis datos de perfil del provider (avatar, email verificado)
Queréis auth social para comunidad
❌ No usad OAuth cuando:
Trabajáis con usuarios que no tienen cuenta en providers populares
Operáis en mercados donde ciertos providers están bloqueados
Necesitáis control total sobre el flujo de verificación
Magic Links
✅ Usad magic links cuando:
Queréis zero password UX
Operáis en sectores regulados donde OAuth puede ser overkill
Necesitáis email verification implícita
❌ No usad magic links cuando:
Usuarios esperan inmediatez total (magic links pueden tardar en llegar)
Operáis en regiones donde emails transaccionales van a spam frecuentemente
Supabase vs Firebase: La Comparación de Auth Que Nadie Hace Bien
En el contexto de 2026, ambos ofrecen auth funcional. Pero las diferencias arquitectónicas importan:
Supabase Auth:
JWTs client-side con gotrue-js
RLS policies server-side
Custom claims via app_metadata
Full SQL access para authorization logic
Firebase Auth:
ID tokens client-side
Security rules server-side (no SQL)
Custom claims via token
Menos flexibilidad para authorization complex logic
La diferencia crucial: Supabase permite authorization decisions basadas en queries SQL contra datos relacionales. Firebase Security Rules es más limitado en exprimir lógica complexa.
Si vuestra auth requiere authorization granular basada en relaciones entre entidades, Supabase gana por arquitectura.
Si sóis un MVP rápido sin relaciones complexas entre usuarios, Firebase es más rápido de setup.
Resumen de Key Takeaways
1. *Nunca confiéis en el JWT como source of truth para authorization decisions*. El cliente controla el token.
2. *User authorization debe vivir en tablas de la base de datos, no en custom claims del JWT*. Las claims son útiles para display, no para security.
3. *Implementad el Patrón de Validación Jerárquica en 3 Capas*: verificación de firma → custom claims vía database → re-validación periódica.
4. *OAuth y magic links son ambos seguros si se implementan con PKCE y validación server-side*. La diferencia está en UX y provider availability.
5. *RLS es una herramienta poderosa, pero no es vuestra única línea de defensa*. Necesitáis architecture que trate el cliente como entorno hostil.
6. *En Supabase vs Firebase, Supabase gana en flexibilidad de authorization* porque permite SQL queries para decisiones complexas sobre relaciones entre entidades.
El auth flow que diseñáis hoy determina vuestra surface de ataque mañana. Tratad cada JWT como potencialmente manipulado, y construiréis sistemas que resisten la realidad del desarrollo en producción.
Lee el artículo completo en brianmenagomez.com
Más sobre mis servicios en brianmenagomez.com
Herramientas: Conversor IAE CNAE · Gestorias cerca de ti · Calculadora IRPF

