Todos los recursos

Llamar a Claude directo desde el navegador le regala tu clave de Anthropic a cualquiera que abra las herramientas de desarrollo, y tu factura a cualquiera que se anime a abusar. Una Edge Function de Supabase es el servidor mínimo que resuelve las dos cosas: guarda la clave, verifica quién está pidiendo, acota lo que puede pedir y reenvía la llamada. Aquí está el montaje completo: función, auth, whitelist del request, rate limiting, streaming y las pruebas que confirman que alguien sin sesión sí recibe un 401.

Proxea Claude por una Edge Function de Supabase: la clave en el servidor, con auth y límites

En resumen

  • La clave de Anthropic nunca llega al navegador. Vive en el almacén de secretos de la Edge Function y solo la función la lee.
  • Valida quién llama antes de hacer cualquier cosa: verifica el JWT de Supabase y rechaza los requests anónimos con un 401, y ya con eso frenaste casi todo el abuso.
  • Nunca reenvíes el body crudo del cliente. Quien llama puede cambiarte el modelo o inflar max_tokens para dispararte la factura. Haz whitelist de los campos y acota los topes en el servidor.
  • Aplica rate limiting por usuario, no por IP. Con una tabla de Postgres y un contador atómico te basta para que una sola cuenta no te vacíe la cuota.
  • Devuelve la respuesta en streaming. La salida de Claude puede ser larga; pasar el stream SSE directo mantiene la latencia baja y evita que la función se quede sin tiempo.

Si tu frontend habla con Claude, la pregunta no es si meter un servidor en medio: es qué tan mínimo puede ser ese servidor sin dejar de hacer las tres cosas que de verdad importan. Tiene que guardar la API key donde el navegador no la pueda leer, decidir si quien pide tiene permiso de pedir, y evitar que una sola persona convierta tu factura de Anthropic en un ataque de denegación de cartera. Una Edge Function de Supabase hace las tres en un solo archivo Deno, desplegado al lado de tu base de datos. Esta guía la arma de punta a punta: la función, auth por JWT, whitelist del request, rate limiting por usuario, streaming y las pruebas que confirman que el camino de rechazo de verdad rechaza.

Uso este mismo patrón en Ilustrari (que opera como NexoString). El trabajo de multi-agente y soporte de decisiones en ThinkTank AI y Proyección llama a Claude, y ninguna de esas llamadas sale de un navegador que tenga la clave. Esta disciplina es lo que mantiene la clave de un fundador en solitario fuera del internet público y la factura bajo control.

Importante

Prerrequisitos: un proyecto de Supabase con la CLI instalada y enlazada (supabase link), una app donde los usuarios se autentican con Supabase Auth para que el request lleve un JWT real, una API key de Anthropic, y suficiente soltura con Deno/TypeScript para leer un handler async con fetch. No te hace falta un backend aparte, la Edge Function es el backend para esta llamada.

01 · Por qué un proxy

Un request desde el navegador queda completamente a la vista de quien lo hace. Abre la pestaña de red y ahí está cada header, incluido x-api-key, en texto plano. No hay ofuscación que aguante a alguien con las herramientas de desarrollo y diez minutos. Así que una clave en el código del cliente es una clave publicada, da por filtrado cualquier proyecto que lo haga así.

El proxy mueve la llamada privilegiada a un lugar que tú controlas. El navegador habla con tu función usando el token de sesión del usuario; la función habla con Anthropic usando la clave secreta. Dos fronteras de confianza separadas, y la peligrosa nunca pisa terreno no confiable.

Hay una segunda razón que la gente suele descubrir por la vía cara. Aunque pudieras esconder la clave en el cliente, igual estarías dejando que el cliente arme el request completo a su antojo: qué modelo, cuántos tokens, cuántas llamadas. El proxy es donde recuperas ese control.

Atención

Si tu clave de Anthropic alguna vez salió en un bundle del frontend, en una variable NEXT_PUBLIC_, o en un .env que subiste al repo y llegó a un repo público, está comprometida. Rótala en la consola de Anthropic antes de hacer cualquier otra cosa aquí. Un proxy protege la clave nueva; no hace nada por la que ya se filtró.

02 · Arma la función y guarda el secreto

Crea la función con la CLI. El nombre pasa a ser parte de la URL, así que elige algo aburrido y estable.

supabase functions new claude-chat

# Pon la clave en el almacén de secretos de la función — nunca en el código, nunca en el cliente.
supabase secrets set ANTHROPIC_API_KEY=sk-ant-...

# Despliega cuando estés listo (vuelve a correrlo después de cada cambio).
supabase functions deploy claude-chat

El secreto que pones con supabase secrets set solo se lee dentro de la función con Deno.env.get, y en ningún otro lado. No va en el bundle, no se manda al cliente y no se vuelve a ver en el dashboard una vez que lo configuras. En esa única propiedad está todo el sentido del ejercicio.

Un detalle sobre la auth de Supabase en Edge Functions

Por defecto, las Edge Functions de Supabase quedan detrás del gateway de la plataforma, que rechaza los requests sin una key válida de Supabase antes de que tu código siquiera corra. Es una primera barrera útil, pero no es autenticación de usuario, verifica que haya una key, no cuál usuario está llamando. De todos modos tienes que verificar el JWT del usuario dentro de la función. Las dos capas se complementan: el gateway deja afuera el ruido aleatorio de internet, y tu código decide quién en concreto tiene permiso.

03 · Verifica quién llama antes de hacer cualquier cosa

Las primeras líneas del handler deben rechazar a quien no sea un usuario con sesión iniciada. Hazlo antes de leer el body, llamar a Anthropic o tocar la base de datos. Un request sin autenticar no debería costarte ni un centavo.

import { createClient } from "jsr:@supabase/supabase-js@2"

Deno.serve(async (req) => {
  // 1. Rechaza de entrada cualquier cosa sin header Authorization.
  const authHeader = req.headers.get("Authorization")
  if (!authHeader) {
    return new Response("unauthorized", { status: 401 })
  }

  // 2. Verifica el JWT contra tu proyecto. getUser() valida la firma del
  //    token y su expiración — no confíes en un token que solo decodificaste.
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: authHeader } } },
  )
  const { data: { user }, error } = await supabase.auth.getUser()
  if (error || !user) {
    return new Response("unauthorized", { status: 401 })
  }

  // De aquí en adelante, `user.id` es un usuario real y verificado. Todo lo
  // de abajo se apoya en ese id — rate limits, logging y cualquier regla por usuario.
  // ... seguimos en los próximos pasos ...
  return new Response("ok")
})

La línea que importa es getUser(). Decodificar un JWT te dice lo que afirma; verificarlo te dice si creerle. Un token decodificado pero no verificado se puede falsificar. Cualquiera arma uno con el user.id que se le antoje. getUser() chequea la firma contra el secreto de tu proyecto, así que un token falsificado se cae aquí.

Consejo

Mantén el body del 401 corto e idéntico para "sin header" y "token inválido". Los errores de auth detallados son un regalo para quien anda sondeando tu endpoint. No tiene por qué saber si el token faltaba, expiró o venía mal formado.

04 · Whitelist del request, acota los topes

Aquí está el error que convierte un proxy funcional en una barra libre: reenviarle a Anthropic el body JSON completo del cliente. Si el cliente puede darle forma al body, quien llama puede poner en model tu modelo más caro, empujar max_tokens al máximo y mandar unos cuantos miles de esos en un loop. Te enteras al cierre del ciclo de facturación.

Arma el request de salida desde cero. Lee solo los campos que piensas exponer, valídalos e impón tus propios topes.

// Dentro del handler, una vez que pasa la auth:
const body = await req.json().catch(() => null)
if (!body || !Array.isArray(body.messages)) {
  return new Response("bad request", { status: 400 })
}

// Arma el request de Anthropic tú mismo. NO hagas `...body`.
const MAX_OUTPUT = 4096
const upstream = {
  model: "claude-opus-4-8",                 // el servidor elige el modelo, no el cliente
  max_tokens: Math.min(body.max_tokens ?? MAX_OUTPUT, MAX_OUTPUT),
  messages: body.messages.slice(-20),       // acota también el largo de la conversación
  stream: true,
}

const res = await fetch("https://api.anthropic.com/v1/messages", {
  method: "POST",
  headers: {
    "x-api-key": Deno.env.get("ANTHROPIC_API_KEY")!,
    "anthropic-version": "2023-06-01",
    "content-type": "application/json",
  },
  body: JSON.stringify(upstream),
})

Ahí hay varias decisiones tomadas a propósito. El model se fija en el servidor. El cliente nunca lo nombra, así que no puede echar mano de una tier más cara. max_tokens se acota con Math.min contra un tope duro, de modo que un cliente que pida 200000 recibe 4096. El arreglo messages se recorta para que nadie pueda pegar un megabyte de historial y volver gigante cada llamada. Hacer whitelist quiere decir que el request de salida lleva exactamente los campos que tú decidiste y nada que el cliente haya colado.

Nota

Sobre el string del modelo: claude-opus-4-8 usa adaptive thinking y la superficie de request actual: sin temperature, sin top_p y sin budget_tokens (esos devuelven un 400). Si quieres controlar la profundidad del razonamiento, usa output_config.effort en lugar de echar mano de los controles viejos. Fija el modelo en el servidor para que una migración de modelo sea un cambio de una línea que tú controlas, y no una sorpresa del lado del cliente.

05 · Rate limiting por usuario con una sola tabla

La auth frena a los extraños; no hace nada contra un usuario con sesión (o una sesión robada) martillando el endpoint. La solución que escala con la menor maquinaria es un contador por usuario en Postgres, chequeado de forma atómica en cada llamada.

create table api_rate_limits (
  user_id uuid not null references auth.users (id),
  window_start timestamptz not null,
  count int not null default 0,
  primary key (user_id, window_start)
);

-- Incremento atómico para una ventana fija (por ejemplo, el minuto actual).
-- Devuelve el conteo ya incrementado para que la función pueda decidir.
create function bump_rate_limit(uid uuid, win timestamptz)
returns int language sql as $func$
  insert into api_rate_limits (user_id, window_start, count)
  values (uid, win, 1)
  on conflict (user_id, window_start)
  do update set count = api_rate_limits.count + 1
  returning count;
$func$;

En la función, redondea el reloj a una ventana, llama a bump_rate_limit y corta con un 429 si el conteo pasa tu límite, antes de gastar un token en Anthropic. Como el incremento y la lectura ocurren en una sola sentencia, dos requests concurrentes no pueden colarse ambos bajo el límite; Postgres serializa el upsert que entra en conflicto.

Por usuario le gana a por IP en una API autenticada. Las IP son compartidas (oficinas, operadoras móviles, VPNs) y se rotan sin el menor esfuerzo; el user.id verificado es lo que de verdad quieres limitar. Si además quieres un cortacircuitos global, digamos, un tope duro para todos los usuarios juntos que proteja la factura pase lo que pase, agrega un segundo contador con clave en una constante. Dos contadores baratos, dos radios de impacto distintos.

06 · Devuelve el stream y llámalo desde el cliente

Las respuestas de Claude pueden ser largas, y un proxy sin streaming deja al usuario mirando un spinner mientras se genera la respuesta completa, y se arriesga a que la función choque con su límite de tiempo de ejecución en una respuesta grande. Como ya pusiste stream: true en el request de salida, pasa ese stream directo de vuelta al navegador.

// Reenvía el stream SSE de Anthropic al cliente tal cual, sin tocarlo.
return new Response(res.body, {
  status: res.status,
  headers: {
    "content-type": "text/event-stream",
    "cache-control": "no-cache",
    "connection": "keep-alive",
  },
})

En el cliente, llama a la URL de la función con el token de sesión del usuario y lee el stream conforme va llegando. El token de sesión es el access_token de la sesión actual de Supabase, nunca la clave service-role, nunca la clave de Anthropic.

El ciclo completo se lee limpio ahora: el navegador manda un token de sesión y un arreglo de mensajes; la función verifica al usuario, chequea el rate limit, arma un request acotado, llama a Claude con la clave secreta y devuelve la respuesta en streaming. La clave se quedó en el servidor todo el tiempo, y en ningún momento el cliente pudo elegir el modelo ni el presupuesto de tokens.

Dos cosas separan un proxy que funciona en un demo de uno que dejarías corriendo en serio. Prueba los caminos de rechazo: un request sin token, un token expirado, un token falsificado y un usuario que se pasó del rate limit deben devolver cada uno el status correcto y cero llamadas a Anthropic. Y vigila la factura la primera semana; un log por usuario del consumo de tokens (un insert más en el handler) convierte el "¿habrá alguien abusando de esto?" de una corazonada en una query.

Puntos clave

  • La clave vive en el almacén de secretos de la función y se lee solo con Deno.env.get. Nunca entra a un bundle, a un cliente ni a una variable NEXT_PUBLIC_.
  • Autentica primero, trabaja después: verifica el JWT con getUser() y rechaza los requests anónimos con un 401 antes de leer el body o llamar a Claude.
  • Arma el request de salida tú mismo: fija el modelo, acota max_tokens con Math.min, recorta el historial de mensajes y nunca hagas spread del body del cliente.
  • Aplica rate limiting por usuario verificado con un contador atómico en Postgres; suma un contador global si quieres un tope duro al gasto total.
  • Pasa el stream SSE directo y prueba cada camino de rechazo (sin token, expirado, falsificado, pasado del límite) para confirmar que cada uno cuesta cero llamadas a Anthropic.

Preguntas frecuentes

¿No me basta con restringir la clave de Anthropic con una allowlist de dominios en vez de montar un proxy?

No, la API de Anthropic no maneja el concepto de allowlist por origen del navegador ni por referrer, y aunque lo manejara, el header Origin se falsifica sin el menor esfuerzo fuera de un navegador. Cualquier clave que llegue al cliente la puede usar quien la copie. El proxy es el único diseño que mantiene la clave del todo fuera del cliente.

¿Por qué verificar yo mismo el JWT si el gateway de Supabase ya bloquea los requests sin key?

El gateway verifica que haya presente alguna key válida de Supabase. La clave publishable califica, y esa clave es pública. No te dice cuál usuario está llamando. getUser() dentro de la función verifica la firma del JWT del usuario y te da un user.id confiable sobre el cual montar rate limits y logging. El gateway y la verificación del JWT son dos capas distintas haciendo dos trabajos distintos.

¿Un contador por minuto en Postgres es lo bastante rápido, o me hace falta Redis?

Para una app de una persona o de un equipo pequeño, el contador en Postgres sobra. Es un upsert indexado por request, corriendo en la misma base de datos con la que la función ya habla, sin infraestructura extra que mantener. Recurre a Redis solo cuando hagas decenas de miles de requests por segundo o necesites ventanas deslizantes repartidas en varias regiones. No agregues una pieza móvil que todavía no te hace falta.

¿Cómo manejo cuando Anthropic le devuelve un 429 o un 529 al proxy?

Esos se pueden reintentar del lado de Anthropic, pero dentro de una sola invocación de la Edge Function normalmente no te conviene reintentar en silencio. Se come el presupuesto de ejecución de tu función y puede ir acumulando latencia. Reenvía al cliente el status y el header retry-after, y deja que sea el cliente quien haga backoff; o encola el trabajo si no es sensible a la latencia. No te tragues el error devolviendo un éxito falso; el usuario tiene que saber que el upstream está sobrecargado.

¿Hacer streaming de la respuesta me rompe el rate limiting o la contabilidad de tokens?

El rate limiting no se ve afectado. Decides si permitir la llamada antes de siquiera abrir el stream. La contabilidad de tokens sí necesita un paso extra: los números finales de uso llegan en los eventos message_delta / message_stop al final del stream, así que si quieres registrar los tokens exactos por usuario, lee el stream mientras pasa (o lee usage del evento final) en vez de adivinar a partir del request. Contar requests es fácil; contar tokens implica leer la cola del stream.

¿Prefieres que lo hagamos por ti?

Esto mismo lo construimos para negocios como el tuyo. La primera conversación es gratis y sin compromiso.

Escríbenos por WhatsApp

Escríbenos por WhatsApp

Escanéalo con tu teléfono para escribirnos por WhatsApp.

Escanéalo con tu teléfono para escribirnos por WhatsApp.

¿Estás desde el teléfono y no puedes escanear? Escríbenos a info@ilustrari.com

Primera conversación gratis. Te responde el fundador.

Recursos relacionados

PromptSupabase

Redactor de políticas RLS: un prompt que escribe políticas de Supabase y luego las ataca

Row Level Security es donde las apps de Supabase se filtran sin que nadie se dé cuenta: una política que se lee bien se olvida del INSERT, confía en una columna que pone el cliente, o cuida el USING pero no el WITH CHECK. Este es el prompt, listo para copiar y pegar, que usamos para redactar el set completo de políticas de una tabla, una política por operación, y después hacer que el modelo le haga red-team a su propio trabajo en una sección de NOTAS DE AMENAZA. Te llevas el template, las variables que tienes que rellenar, variantes para multi-tenant y storage, y los detalles que convierten una política de aspecto impecable en una lectura cruzada entre tenants.

12 abr 202612 min de lectura
GuíaClaude Code

Conecta un servidor MCP a Claude Code

Registra un servidor MCP para que Claude Code deje de adivinar sobre tu base de datos, tu API o tu sistema de archivos y llame herramientas reales, con alcance acotado, sin filtrar secretos y versionado en el repo.

10 may 202611 min de lectura
GuíaSupabase

Claves de API con alcance y RLS en Supabase: deja que la base de datos decida quién lee qué

Supabase te entrega dos claves con radios de impacto muy distintos, y casi todas las fugas salen de poner la equivocada en el navegador. Este es el recorrido completo que sigo en cada proyecto: elige la clave correcta para cada superficie, activa Row Level Security, escribe políticas que aguanten un ataque y comprueba que el camino de negación de verdad niega, con los comandos y los tropiezos que le cuestan caro a la gente en producción.

9 may 202612 min de lectura