Ir al contenido
Infraestructura & Tecnología

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.

Mansoor Ahmed
Mansoor Ahmed
Head of Engineering 16 min de lectura

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-runtime y 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.

  1. Supabase Self-Hosting Runbook
  2. Next.js sobre Supabase de forma segura
  3. Supabase Edge Functions de forma segura - este artículo
  4. Trigger.dev Background Jobs en producción segura
  5. Claude Code como control de seguridad en el workflow DevOps
  6. 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

CriterioEdge FunctionServer ActionTrigger.dev Task
OrigenEvento externo (webhook)Entrada del usuarioTarea interna
Tiempo de ejecución< 30 segundos< 10 segundosHasta 5 minutos+
AutenticaciónFirma HMACSesión de usuario (JWT)Clave API / service_role
RLSOmitido (service_role)Activo (anon key)Omitido (service_role)
RetryProveedor del webhookNinguno (síncrono)Integrado (Trigger.dev)
EjemploStripe checkout webhookFormulario de perfilGeneració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.

  1. Supabase Self-Hosting Runbook
  2. Next.js sobre Supabase de forma segura
  3. Supabase Edge Functions de forma segura - este artículo
  4. Trigger.dev Background Jobs en producción segura
  5. Claude Code como control de seguridad en el workflow DevOps
  6. 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

Bert Gogolin

Director General, Gosign

AI Governance Briefing

IA empresarial, regulación e infraestructura - una vez al mes, directamente de mi parte.

Sin spam. Cancelable en cualquier momento. Política de privacidad

Supabase Edge Functions Deno Webhooks Security
Compartir este artículo

Preguntas frecuentes

¿Para qué sirven las Supabase Edge Functions?

Las Edge Functions son adecuadas para webhooks, integraciones con APIs externas y endpoints de eventos firmados. No son una alternativa a las Next.js Server Actions para lógica de negocio ni un sustituto de Trigger.dev para procesos de larga duración.

¿Por qué cada endpoint de webhook debe verificar la firma?

Sin verificación de firma, cualquiera puede enviar payloads arbitrarios al webhook. Un atacante podría generar eventos falsos, por ejemplo una confirmación de checkout falsa en Stripe, sin que se haya realizado ningún pago.

¿Por qué los webhooks necesitan verificación de firma pero no JWT?

Los webhooks son llamados por servicios externos como Stripe o GitHub que no disponen de un JWT de Supabase. En su lugar, firman el payload con un secret compartido (HMAC). La Edge Function debe verificar esta firma porque de lo contrario cualquiera puede enviar payloads arbitrarios.