Supabase Edge Functions de forma segura
Runbook DevOps para Supabase Edge Functions: webhooks, firmas, CORS, Deno runtime, validación de entrada e integración con Claude Code.
Estadística: Según datos de Stripe, más del 12% de las integraciones de webhooks no verifican firmas, lo que las hace vulnerables a eventos falsificados.
Las Supabase Edge Functions son funciones TypeScript del lado del servidor que se ejecutan en el runtime de Deno y son accesibles a través del Kong API Gateway.
Son especialmente adecuadas para webhooks, integraciones con APIs externas, endpoints de eventos firmados y lógica ligera del lado del servidor.
El error más frecuente es utilizar las Edge Functions como un segundo sistema backend. Esto genera lógica de negocio duplicada, límites de seguridad difusos y arquitecturas difíciles de depurar.
Este runbook describe cómo utilizar las Edge Functions de forma segura en un setup self-hosted. Cada paso contiene una implementación concreta con código Deno real, una condición verificable y un escenario de fallo.
Nota sobre el runtime: Las Supabase Edge Functions se basan en Deno, no en Node.js. Esto afecta a la sintaxis de importación, el sistema de módulos y las APIs disponibles. Todos los ejemplos de código en este artículo son compatibles con Deno. Las Edge Functions self-hosted se ejecutan en el contenedor
supabase/edge-runtimey actualmente están marcadas como Beta.
De un vistazo - Artículo 3 de 6 de la serie DevOps Runbook
- Edge Functions solo como puntos de integración (webhooks), no segundo backend
- Verificación obligatoria de firmas de webhooks
- CORS configurado explícitamente (sin wildcard)
- Validación de entrada con Zod
- Tareas de larga duración delegadas a Trigger.dev
Índice de la serie
Esta guía forma parte de nuestra serie de runbooks DevOps para app stacks self-hosted.
- Supabase Self-Hosting Runbook
- Next.js sobre Supabase de forma segura
- Supabase Edge Functions de forma segura - este artículo
- Trigger.dev Background Jobs en producción segura
- Claude Code como control de seguridad en el workflow DevOps
- Security Baseline para todo el stack
El artículo 1 describe la plataforma. El artículo 2 describe la capa de aplicación Next.js. Este artículo describe integraciones y webhooks.
Visión general de la arquitectura
Browser
|
Next.js (capa de aplicación)
|
+-- Supabase Client (anon key) -> para solicitudes de usuario
|
Kong API Gateway
|
+-- PostgREST -> REST API con RLS
+-- GoTrue -> Auth
+-- Edge Runtime -> Edge Functions
| |
| +-- stripe-webhook
| +-- github-webhook
| +-- trigger-webhook
|
PostgreSQL + RLS Policies
Servicios externos
|
+-- Stripe / GitHub / Trigger.dev -> invocan webhooks
Regla fundamental:
Las Edge Functions son puntos de integración para eventos externos.
La lógica de negocio pertenece a [Next.js](/es/revista/nextjs-supabase-configuracion-segura/) (Server Actions / Route Handler).
Los procesos de larga duración pertenecen a [Trigger.dev](/es/revista/trigger-dev-background-jobs/).
Tabla de decisión
| Criterio | Edge Function | Server Action | Trigger.dev Task |
|---|---|---|---|
| Origen | Evento externo (webhook) | Entrada del usuario | Tarea interna |
| Tiempo de ejecución | < 30 segundos | < 10 segundos | Hasta 5 minutos+ |
| Autenticación | Firma HMAC | Sesión de usuario (JWT) | Clave API / service_role |
| RLS | Omitido (service_role) | Activo (anon key) | Omitido (service_role) |
| Retry | Proveedor del webhook | Ninguno (síncrono) | Integrado (Trigger.dev) |
| Ejemplo | Stripe checkout webhook | Formulario de perfil | Generación de PDF |
Criterio de decisión
Si una función reacciona a un evento externo (Stripe Payment, GitHub Push, Trigger.dev Callback), es una Edge Function. Si reacciona a input de usuario y transforma datos, pertenece a Next.js. Si se ejecuta durante más de 30 segundos, pertenece a Trigger.dev.
Cómo funcionan las Edge Functions en el setup self-hosted
En el stack Docker Compose self-hosted, la configuración de Edge Functions tiene este aspecto:
# docker-compose.yml (extracto)
functions:
container_name: supabase-edge-functions
image: supabase/edge-runtime:v1.66.4 # fijar versión
restart: unless-stopped
depends_on:
- analytics
environment:
JWT_SECRET: ${JWT_SECRET}
SUPABASE_URL: http://kong:8000 # hostname interno de Docker
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
volumes:
- ./volumes/functions:/home/deno/functions:Z
command:
- start
- --main-service
- /home/deno/functions/main
Las Edge Functions se encuentran como archivos TypeScript en el volumen:
volumes/functions/
main/
index.ts <- Router / Main Service
_shared/
cors.ts <- CORS Headers (compartido)
supabase-client.ts <- Supabase Client Factory (compartido)
stripe-webhook/
index.ts
github-webhook/
index.ts
Despliegue en el setup self-hosted: Las funciones se colocan como archivos en el volumen y se reinicia el contenedor:
# Copiar funciones al volumen
cp -r supabase/functions/* /opt/supabase/volumes/functions/
# Reiniciar el contenedor
docker compose restart functions --no-deps
Parte A - Decisiones de arquitectura
Estas decisiones se toman una vez y rara vez se modifican.
A1 - Edge Functions solo para integraciones, no como segundo backend
Implementación
Asignación clara de qué lógica pertenece a cada componente:
Edge Functions (Deno):
Recepción de webhooks (Stripe, GitHub, APIs externas)
Sincronización basada en eventos (DB Webhook -> servicio externo)
Verificación de firmas de eventos entrantes
Transformaciones ligeras (< 30 segundos)
Next.js (Server Actions / Route Handler):
Mutaciones orientadas al usuario (CRUD)
Operaciones basadas en sesión
Lógica de negocio
Validación de entrada con contexto de usuario
Trigger.dev (Background Jobs):
Generación de PDF
Tareas de IA
Envío masivo de correos
Todo lo que supere los 30 segundos de ejecución
Condición verificable
# ¿Cuántas Edge Functions existen?
ls -d volumes/functions/*/ | grep -v "main\|_shared" | wc -l
# Para cada función verificar: ¿es una integración?
for dir in volumes/functions/*/; do
name=$(basename "$dir")
[[ "$name" == "main" || "$name" == "_shared" ]] && continue
echo "--- $name ---"
# ¿Contiene patterns de webhook/integración?
grep -l "signature\|webhook\|stripe\|github\|trigger" "$dir"*.ts 2>/dev/null || \
echo "ADVERTENCIA: no se encontraron patterns de webhook/integración"
done
Escenario de fallo
Si la lógica de negocio se implementa en las Edge Functions, surge una arquitectura sombra: la misma validación existe en las Next.js Server Actions Y en las Edge Functions, con diferencias sutiles. Los bugs se vuelven difíciles de reproducir porque no queda claro qué ruta de código estaba activa. La lógica de autenticación debe mantenerse en dos lugares.
A2 - Aislar endpoints de webhooks: una función por webhook
Implementación
Cada proveedor de webhooks recibe su propia función en un directorio propio:
volumes/functions/
stripe-webhook/
index.ts
github-webhook/
index.ts
trigger-webhook/
index.ts
No de esta manera:
volumes/functions/
webhooks/
index.ts <- procesa Stripe, GitHub y Trigger en un solo archivo
Condición verificable
# Cada función de webhook debe atender a un solo proveedor
for dir in volumes/functions/*-webhook/; do
name=$(basename "$dir")
providers=$(grep -ciE "stripe|github|trigger|slack|sendgrid" "$dir/index.ts")
if [ "$providers" -gt 1 ]; then
echo "ADVERTENCIA: $name atiende a varios proveedores ($providers)"
fi
done
Escenario de fallo
Si varios webhooks se procesan en una sola función, comparten el mismo manejador de errores. Un payload defectuoso de Stripe puede bloquear el webhook de GitHub. Las estrategias de reintento difieren según el proveedor (Stripe reintenta con backoff exponencial, GitHub solo 3 veces), lo que difícilmente se puede implementar de forma limpia en una función compartida.
A3 - Configurar CORS correctamente
Implementación
Las Edge Functions que se invocan desde el navegador (también indirectamente) necesitan cabeceras CORS. Supabase no proporciona una configuración CORS automática para las Edge Functions.
Configuración CORS compartida:
// volumes/functions/_shared/cors.ts
const allowedOrigins = [
'https://app.example.com',
...(Deno.env.get('ENVIRONMENT') === 'development'
? ['http://localhost:3000']
: [])
]
export function getCorsHeaders(req: Request) {
const origin = req.headers.get('origin') ?? ''
const corsOrigin = allowedOrigins.includes(origin) ? origin : ''
return {
'Access-Control-Allow-Origin': corsOrigin,
'Access-Control-Allow-Headers':
'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
}
}
Cada Edge Function debe gestionar CORS al principio:
import { getCorsHeaders } from '../_shared/cors.ts'
Deno.serve(async (req) => {
const corsHeaders = getCorsHeaders(req)
// El preflight CORS debe ser la PRIMERA verificación
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
// ... lógica principal
return new Response(JSON.stringify(data), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
})
Condición verificable
# Todas las funciones deben gestionar CORS
for dir in volumes/functions/*/; do
name=$(basename "$dir")
[[ "$name" == "main" || "$name" == "_shared" ]] && continue
if ! grep -q "OPTIONS" "$dir/index.ts" 2>/dev/null; then
echo "ADVERTENCIA: $name no tiene un manejador OPTIONS"
fi
done
# Sin CORS wildcard en producción
grep -r "'\\*'" volumes/functions/ --include="*.ts" | grep -i "allow-origin"
# Expectativa: sin resultados (excepto en ramas de desarrollo)
Escenario de fallo
Sin cabeceras CORS, todas las solicitudes del navegador a las Edge Functions fallan. El navegador bloquea la respuesta, aunque la función responda correctamente. Esto se manifiesta como un error CORS críptico en la consola. Con CORS wildcard ('*') en producción, cualquier sitio web puede enviar solicitudes a las Edge Functions y leer las respuestas.
Parte B - Verificaciones de implementación
Estas verificaciones se aplican a cada Edge Function y deben comprobarse en cada despliegue.
B1 - Verificar firmas de webhooks
Implementación
Cada endpoint de webhook debe verificar la firma del servicio emisor. Sin verificación de firma, cualquiera puede enviar payloads arbitrarios a la función.
Ejemplo completo de webhook de Stripe:
// volumes/functions/stripe-webhook/index.ts
import { getCorsHeaders } from '../_shared/cors.ts'
const STRIPE_WEBHOOK_SECRET = Deno.env.get('STRIPE_WEBHOOK_SECRET')
Deno.serve(async (req) => {
const corsHeaders = getCorsHeaders(req)
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
// 1. Permitir solo POST
if (req.method !== 'POST') {
return new Response(
JSON.stringify({ error: 'Method not allowed' }),
{ status: 405, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// 2. Verificar firma
const signature = req.headers.get('stripe-signature')
if (!signature) {
return new Response(
JSON.stringify({ error: 'Missing signature' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const body = await req.text()
const isValid = await verifyStripeSignature(body, signature, STRIPE_WEBHOOK_SECRET!)
if (!isValid) {
return new Response(
JSON.stringify({ error: 'Invalid signature' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// 3. Procesar evento
const event = JSON.parse(body)
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object)
break
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object)
break
default:
// Ignorar eventos desconocidos, no fallar
console.log(`Unhandled event type: ${event.type}`)
}
// 4. Siempre devolver 200 (de lo contrario Stripe reintenta)
return new Response(
JSON.stringify({ received: true }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
})
// Verificación de firma de Stripe con Deno Crypto API
async function verifyStripeSignature(
payload: string,
header: string,
secret: string
): Promise<boolean> {
const parts = header.split(',')
const timestamp = parts.find(p => p.startsWith('t='))?.split('=')[1]
const signature = parts.find(p => p.startsWith('v1='))?.split('=')[1]
if (!timestamp || !signature) return false
// Verificación temporal: rechazar eventos con más de 5 minutos
const now = Math.floor(Date.now() / 1000)
if (now - parseInt(timestamp) > 300) return false
const signedPayload = `${timestamp}.${payload}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(signedPayload)
)
const expectedSig = Array.from(new Uint8Array(sig))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
return expectedSig === signature
}
async function handleCheckoutCompleted(session: Record<string, unknown>) {
console.log(`Checkout completed: ${session.id}`)
}
async function handlePaymentFailed(invoice: Record<string, unknown>) {
console.log(`Payment failed: ${invoice.id}`)
}
Verificación de firma de webhook de GitHub (procedimiento diferente):
// volumes/functions/github-webhook/index.ts
const GITHUB_WEBHOOK_SECRET = Deno.env.get('GITHUB_WEBHOOK_SECRET')
async function verifyGitHubSignature(
payload: string,
signatureHeader: string,
secret: string
): Promise<boolean> {
// GitHub utiliza sha256=<hex>
const expected = signatureHeader.replace('sha256=', '')
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(payload)
)
const computed = Array.from(new Uint8Array(sig))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
return computed === expected
}
Condición verificable
# Cada función de webhook debe tener una verificación de firma
for dir in volumes/functions/*-webhook/; do
name=$(basename "$dir")
if ! grep -qE "signature|verify|hmac" "$dir/index.ts" 2>/dev/null; then
echo "CRÍTICO: $name no tiene verificación de firma"
else
echo "OK: $name verifica firmas"
fi
done
# Verificar configuración de VERIFY_JWT
# Los webhooks necesitan VERIFY_JWT=false porque los servicios externos no envían JWT de Supabase
grep "VERIFY_JWT" .env
Escenario de fallo
Sin verificación de firma, un atacante puede enviar eventos arbitrarios al webhook:
curl -X POST https://app.example.com/functions/v1/stripe-webhook \
-H "Content-Type: application/json" \
-d '{"type":"checkout.session.completed","data":{"object":{"customer":"cus_fake"}}}'
Esta solicitud generaría una confirmación de checkout falsa. La función actualizaría el estado del usuario aunque nunca se haya realizado un pago.
B2 - Inicializar el cliente Supabase correctamente (anon vs. service_role)
Implementación
Las Edge Functions tienen acceso a todas las variables de entorno de Supabase. La elección entre la clave anon y la clave service_role es la decisión de seguridad más importante por función.
Client Factory compartida:
// volumes/functions/_shared/supabase-client.ts
import { createClient, SupabaseClient } from 'npm:@supabase/supabase-js@2'
// Cliente CON RLS (para operaciones con contexto de usuario)
export function createAnonClient(authHeader?: string): SupabaseClient {
const client = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{
global: {
headers: authHeader ? { Authorization: authHeader } : {},
},
}
)
return client
}
// Cliente SIN RLS (para procesamiento de webhooks, tareas administrativas)
// ATENCIÓN: Omite todas las políticas de Row Level Security
export function createAdminClient(): SupabaseClient {
return createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
}
Cuándo usar cada cliente:
createAnonClient(authHeader):
Funciones orientadas al usuario (poco frecuente, normalmente pertenecen a Next.js)
Cuando RLS debe controlar el acceso
createAdminClient():
Procesamiento de webhooks (Stripe, GitHub)
Tareas de sincronización internas
Cuando la función NO tiene contexto de usuario
Regla general: los webhooks no tienen contexto de usuario,
por lo tanto necesitan service_role.
Pero: ejecutar solo las operaciones mínimas necesarias.
Condición verificable
# ¿Dónde se utiliza service_role / Admin Client?
grep -rn "SERVICE_ROLE\|createAdminClient\|service_role" \
volumes/functions/ --include="*.ts" | grep -v "_shared/"
# Expectativa: solo en funciones de webhook, no en funciones orientadas al usuario
# ¿Se alimenta el Admin Client con input de usuario no validado?
# Revisión manual: en cada función que utilice createAdminClient,
# verificar que el input se valida antes de la operación en la base de datos
Escenario de fallo
Si una Edge Function se ejecuta con service_role y pasa input de usuario directamente a las consultas, omite RLS por completo. Un payload de webhook manipulado podría entonces leer o escribir datos arbitrarios en cualquier tabla, porque la clave service_role no tiene restricciones.
B3 - Validación de entrada
Implementación
Cada Edge Function debe validar los datos entrantes antes de procesarlos. En Deno, Zod funciona mediante importación npm:
// volumes/functions/stripe-webhook/index.ts (extracto)
import { z } from 'npm:zod@3'
// Schema para el evento Stripe esperado
const stripeEventSchema = z.object({
id: z.string().startsWith('evt_'),
type: z.string(),
data: z.object({
object: z.record(z.unknown()),
}),
})
// En la función principal después de la verificación de firma:
const parsed = stripeEventSchema.safeParse(JSON.parse(body))
if (!parsed.success) {
return new Response(
JSON.stringify({ error: 'Invalid payload structure' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// A partir de aquí trabajar con parsed.data (con tipos seguros)
const event = parsed.data
Condición verificable
# Cada función debe tener validación de entrada
for dir in volumes/functions/*/; do
name=$(basename "$dir")
[[ "$name" == "main" || "$name" == "_shared" ]] && continue
if grep -qE "zod|safeParse|z\.object|z\.string" "$dir/index.ts" 2>/dev/null; then
echo "OK: $name tiene validación de schema"
elif grep -qE "JSON\.parse" "$dir/index.ts" 2>/dev/null; then
echo "ADVERTENCIA: $name parsea JSON sin validación de schema"
fi
done
Escenario de fallo
Sin validación de entrada, la función acepta cualquier payload que tenga una firma válida. Una clave API comprometida del proveedor de webhooks podría entonces enviar estructuras de datos inesperadas que provoquen operaciones indefinidas en la base de datos, por ejemplo undefined como ID de usuario en un insert.
B4 - Secrets en variables de entorno, nunca en el código
Implementación
En el setup self-hosted, los secrets se pasan a las Edge Functions a través de la configuración de Docker Compose:
# docker-compose.yml (extracto)
functions:
environment:
JWT_SECRET: ${JWT_SECRET}
SUPABASE_URL: http://kong:8000
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
env_file:
- .env.functions # secrets adicionales para las funciones
# .env.functions (solo en el servidor, no en Git)
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_SECRET_KEY=sk_live_...
GITHUB_WEBHOOK_SECRET=ghsec_...
TRIGGER_DEV_API_KEY=tr_...
Acceder en el código:
// Así: variable de entorno
const secret = Deno.env.get('STRIPE_WEBHOOK_SECRET')
// NUNCA así: hardcoded
const secret = 'whsec_abc123...'
Para los fundamentos generales de la gestión de secrets, consulta Seguridad de datos en la infraestructura IA enterprise.
Condición verificable
# ¿Secrets hardcoded en el código?
grep -rn "sk_live\|sk_test\|whsec_\|ghsec_\|Bearer ey" \
volumes/functions/ --include="*.ts"
# Expectativa: sin resultados
# ¿.env.functions no está en Git?
git ls-files .env.functions
# Expectativa: vacío
# ¿Permisos de archivo correctos?
stat -c "%a" .env.functions
# Expectativa: 600
Escenario de fallo
Los secrets hardcoded en el código fuente acaban en el repositorio Git. Incluso si el repositorio es privado, todos los desarrolladores con acceso al repositorio tienen también acceso a los secrets de producción. En caso de un push público accidental, los secrets quedan comprometidos de inmediato.
B5 - Evitar timeouts y procesos de larga duración
Implementación
Las Edge Functions están diseñadas para operaciones cortas e idempotentes. El Edge Runtime self-hosted tiene un timeout por defecto (configurable, típicamente 60 segundos para self-hosted). Los procesos de larga duración bloquean slots de workers.
Adecuado (< 30 segundos):
Recibir webhook y actualizar la base de datos
Verificar firma y reenviar evento
Llamadas API cortas a servicios externos
NO adecuado (usar Trigger.dev):
Generación de PDF
Inferencia de IA (llamadas a LLM)
Procesamiento de vídeo/imagen
Operaciones masivas en la base de datos
Envío de correos a muchos destinatarios
Patrón para procesos de larga duración: Edge Function como disparador, job en Trigger.dev:
// volumes/functions/process-document/index.ts
// CORRECTO: la Edge Function solo dispara el job
Deno.serve(async (req) => {
// ... verificar firma, validar entrada ...
// Delegar el proceso de larga duración a Trigger.dev
const triggerResponse = await fetch(
`${Deno.env.get('TRIGGER_DEV_URL')}/api/v1/tasks/process-document/trigger`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${Deno.env.get('TRIGGER_DEV_API_KEY')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
payload: { documentId: parsed.data.documentId },
}),
}
)
// Responder de inmediato, el job se ejecuta en segundo plano
return new Response(
JSON.stringify({ queued: true }),
{ status: 202, headers: { 'Content-Type': 'application/json' } }
)
})
Condición verificable
# Buscar patterns que indiquen procesos de larga duración
grep -rn "await.*fetch.*openai\|pdf\|sharp\|ffmpeg\|sleep\|setTimeout" \
volumes/functions/ --include="*.ts" | grep -v "trigger"
# Expectativa: sin resultados (excepto llamadas API cortas)
# ¿Hay timeouts en el código?
grep -rn "AbortSignal.timeout\|setTimeout" \
volumes/functions/ --include="*.ts"
# Cada fetch externo debería tener un timeout
Escenario de fallo
Una Edge Function que espera 2 minutos a una llamada API de IA bloquea un slot de worker en el contenedor Edge Runtime. Con varias solicitudes simultáneas, los workers se saturan y las llamadas de webhook posteriores (por ejemplo de Stripe) fallan con timeouts. Stripe interpreta esto como un error y reintenta, lo que agrava la situación.
B6 - Manejo de errores y respuestas seguras
Implementación
Las Edge Functions no deben filtrar detalles internos al llamador en caso de error.
// Patrón para manejo seguro de errores
Deno.serve(async (req) => {
const corsHeaders = getCorsHeaders(req)
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
// ... lógica principal ...
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
} catch (error) {
// Registrar internamente (detalles)
console.error(`Function error: ${error.message}`, {
stack: error.stack,
// NO registrar secrets ni datos de usuario
})
// Responder externamente (genérico)
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
})
Lo que NO debe incluirse en respuestas ni logs:
// INCORRECTO: enviar stack trace al cliente
return new Response(JSON.stringify({ error: error.stack }), { status: 500 })
// INCORRECTO: registrar secrets
console.log(`Connecting with key: ${Deno.env.get('SERVICE_ROLE_KEY')}`)
// INCORRECTO: registrar datos completos del usuario
console.log(`Processing user: ${JSON.stringify(user)}`)
Condición verificable
# ¿Se envían detalles de error al cliente?
grep -rn "error\.stack\|error\.message" volumes/functions/ --include="*.ts" | \
grep "Response"
# Expectativa: sin resultados (stack/message solo en console.error)
# ¿Se registran secrets?
grep -rn "console\.log.*KEY\|console\.log.*SECRET\|console\.log.*token" \
volumes/functions/ --include="*.ts"
# Expectativa: sin resultados
Escenario de fallo
Si se envía un stack trace de error al cliente, un atacante ve rutas internas, nombres de módulos y detalles de conexión a la base de datos. Esto facilita considerablemente los ataques dirigidos. Si los secrets aparecen en los logs, son visibles para cualquier persona con acceso a la monitorización.
Parte C - Operación y monitorización
C1 - Workflow de despliegue
Implementación
En el setup self-hosted no existe un comando supabase functions deploy. El workflow se basa en archivos:
#!/bin/bash
# scripts/deploy-functions.sh
set -euo pipefail
FUNCTIONS_DIR="/opt/supabase/volumes/functions"
SOURCE_DIR="./supabase/functions"
echo "Deploying Edge Functions..."
# 1. Verificación de sintaxis (Deno)
for file in $(find "$SOURCE_DIR" -name "*.ts" -not -path "*/_shared/*"); do
deno check "$file" 2>/dev/null || {
echo "ERROR: error de sintaxis en $file"
exit 1
}
done
# 2. Copiar funciones
rsync -av --delete \
--exclude='*.test.ts' \
"$SOURCE_DIR/" "$FUNCTIONS_DIR/"
# 3. Reiniciar contenedor
docker compose restart functions --no-deps
# 4. Health Check
sleep 5
HEALTH=$(curl -s -o /dev/null -w "%{http_code}" \
http://localhost:8000/functions/v1/hello 2>/dev/null || echo "000")
if [ "$HEALTH" = "200" ] || [ "$HEALTH" = "401" ]; then
echo "Despliegue de Edge Functions completado con éxito"
else
echo "ADVERTENCIA: Health Check con estado $HEALTH"
fi
Condición verificable
# ¿Está corriendo el contenedor Edge Runtime?
docker compose ps functions --format '{{.State}}'
# Expectativa: running
# ¿Son accesibles las funciones?
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer ${ANON_KEY}" \
http://localhost:8000/functions/v1/stripe-webhook
# Expectativa: 200 o 405 (Method Not Allowed, porque es GET en lugar de POST)
C2 - Integración con Claude Code
Claude Code verifica las Edge Functions de forma contextual como complemento a las verificaciones deterministas.
Arquitectura
Git Push / PR
|
+-- Verificaciones deterministas (CI/CD)
| +-- grep en busca de secrets hardcoded
| +-- grep en busca de verificación de firma ausente
| +-- grep en busca de validación de entrada ausente
| +-- grep en busca de detalles de error en respuestas
| +-- Deno Type Check
|
+-- Análisis de Claude Code (semanal o en PR)
+-- ¿Funciones nuevas sin verificación de firma?
+-- ¿Uso de service_role apropiado?
+-- ¿Patterns de procesos de larga duración detectados?
+-- ¿Deriva arquitectónica (lógica de negocio en Functions)?
+-- ¿CORS correcto para funciones nuevas?
Script CI concreto
#!/bin/bash
# scripts/check-edge-functions.sh
REPORT=""
FUNCTIONS_DIR="volumes/functions"
# 1. Secrets hardcoded
SECRETS=$(grep -rn "sk_live\|sk_test\|whsec_\|Bearer ey" \
"$FUNCTIONS_DIR" --include="*.ts" 2>/dev/null)
if [ -n "$SECRETS" ]; then
REPORT+="CRÍTICO: secrets hardcoded encontrados:\n$SECRETS\n\n"
fi
# 2. Funciones de webhook sin verificación de firma
for dir in "$FUNCTIONS_DIR"/*-webhook/; do
[ -d "$dir" ] || continue
name=$(basename "$dir")
if ! grep -qE "signature|verify|hmac|crypto" "$dir/index.ts" 2>/dev/null; then
REPORT+="CRÍTICO: $name no tiene verificación de firma\n"
fi
done
# 3. Funciones sin validación de entrada
for dir in "$FUNCTIONS_DIR"/*/; do
name=$(basename "$dir")
[[ "$name" == "main" || "$name" == "_shared" ]] && continue
if grep -q "JSON.parse" "$dir/index.ts" 2>/dev/null && \
! grep -qE "zod|safeParse|z\." "$dir/index.ts" 2>/dev/null; then
REPORT+="ADVERTENCIA: $name parsea JSON sin validación de schema\n"
fi
done
# 4. Detalles de error en respuestas
LEAKS=$(grep -rn "error\.stack\|error\.message" "$FUNCTIONS_DIR" --include="*.ts" | \
grep "Response" 2>/dev/null)
if [ -n "$LEAKS" ]; then
REPORT+="ADVERTENCIA: detalles de error en respuestas:\n$LEAKS\n\n"
fi
# 5. CORS Wildcards
WILDCARDS=$(grep -rn "'\\*'" "$FUNCTIONS_DIR" --include="*.ts" | grep -i "origin" 2>/dev/null)
if [ -n "$WILDCARDS" ]; then
REPORT+="ADVERTENCIA: CORS wildcard encontrado:\n$WILDCARDS\n\n"
fi
# 6. Patterns de procesos de larga duración
LONG=$(grep -rn "openai\|sharp\|ffmpeg\|puppeteer" "$FUNCTIONS_DIR" --include="*.ts" 2>/dev/null)
if [ -n "$LONG" ]; then
REPORT+="ADVERTENCIA: posibles patterns de procesos de larga duración:\n$LONG\n\n"
fi
# Salida
if [ -n "$REPORT" ]; then
echo -e "=== Edge Function Security Check ===\n"
echo -e "$REPORT"
else
echo "Todas las verificaciones de Edge Functions superadas."
fi
Claude no ejecuta cambios automáticos en producción.
Lista de verificación de despliegue
Comprobar antes de cada despliegue de Edge Functions:
Arquitectura
[ ] La función es una integración (webhook/evento), no lógica de negocio
[ ] Un proveedor de webhook por función
[ ] Procesos de larga duración delegados a Trigger.dev
Seguridad
[ ] Se verifica la firma del webhook
[ ] Se valida el input con schema (p. ej. Zod)
[ ] Elección correcta del cliente (anon vs. service_role)
[ ] service_role solo cuando no hay contexto de usuario
Secrets
[ ] Sin secrets hardcoded en el código
[ ] Secrets en .env.functions (no en Git)
[ ] .env.functions con permisos 600
CORS
[ ] Manejador OPTIONS presente
[ ] Sin wildcard-origin en producción
[ ] Cabeceras CORS en TODAS las respuestas (también errores)
Manejo de errores
[ ] Try/Catch alrededor de toda la lógica
[ ] Mensajes de error genéricos al cliente
[ ] Detalles solo en console.error (sin secrets)
Despliegue
[ ] Deno Type Check superado
[ ] Contenedor reiniciado tras el despliegue
[ ] Health Check tras el despliegue exitoso
Conclusión
Las Supabase Edge Functions son un punto de integración potente cuando se utilizan como tal: recibir webhooks, verificar firmas, procesar eventos y reenviarlos.
El punto decisivo es la delimitación. Las Edge Functions no son un segundo backend junto a Next.js ni un job runner junto a Trigger.dev. Quien trace esta frontera con claridad e implemente los fundamentos de seguridad (firmas, validación de entrada, cliente correcto, CORS), puede operar las Edge Functions de forma segura en un setup self-hosted.
La combinación de verificaciones deterministas en el CI y análisis contextual de Claude Code cubre tanto los patterns conocidos como los riesgos nuevos e inesperados. Quien apuesta desde el principio por Cert-Ready-by-Design se ahorra rondas de auditoría posteriores.
Descarga de la lista de verificación
Prompt preparado para Claude Code. Suba el archivo a su servidor e inicie Claude Code en el directorio del proyecto de sus Edge Functions. Claude Code verificará automáticamente todos los puntos de seguridad de este runbook e informará APROBADO, ADVERTENCIA o CRÍTICO.
claude -p "$(cat claude-check-artikel-3-edge-functions-es.md)" --allowedTools Read,Grep,Glob,Bash
Descargar checklistÍndice de la serie
Este artículo forma parte de nuestra serie DevOps para app stacks self-hosted.
- Supabase Self-Hosting Runbook
- Next.js sobre Supabase de forma segura
- Supabase Edge Functions de forma segura - este artículo
- Trigger.dev Background Jobs en producción segura
- Claude Code como control de seguridad en el workflow DevOps
- Security Baseline para todo el stack
En el próximo artículo mostramos cómo operar Trigger.dev Background Jobs de forma segura sin crear nuevos riesgos de seguridad en el stack.

Bert Gogolin
Director General, Gosign
AI Governance Briefing
IA empresarial, regulación e infraestructura - una vez al mes, directamente de mi parte.