Korzen
Bot Protection

Supabase Auth

Bot Protection con Supabase Auth: registros a través de tu servidor y un hook Before User Created que rechaza los que se lo saltaron.

Con Supabase Auth, el navegador puede crear una cuenta llamando a Supabase directamente, y tu servidor nunca ve el registro. Dos piezas cierran ese hueco: tu servidor hace el registro, después de preguntar a Bot Protection, y un hook Before User Created rechaza los registros que no pasaron por él. Esta guía usa Next.js con @supabase/ssr; cualquier servidor funciona igual.

El tag

Como en la guía de Next.js, en el root layout. La función decide también es la de esa guía.

La llamada

La server action que hace el registro. Pregunta a Bot Protection y entrega a Supabase un pase: el email y la hora, firmados con SIGNUP_PASS_SECRET, un secreto aleatorio que generas y das a los dos lados.

// app/signup/actions.js
"use server";
import { createHmac } from "node:crypto";
import { decide } from "@/lib/korzen"; // from the Next.js guide
import { createClient } from "@/utils/supabase/server";

export async function signUp(prevState, formData) {
  const email = String(formData.get("email")).toLowerCase();
  const d = await decide("/signup");
  if (d?.action === "block") return { error: "We could not create your account." };
  // "challenge": your CAPTCHA, or a phone check, before going on
  const supabase = await createClient();
  const { error } = await supabase.auth.signUp({
    email,
    password: String(formData.get("password")),
    options: { data: { korzen_pass: pass(email), korzen_request_id: d?.request_id ?? null } },
  });
  // ...
}

// pass tells the hook this sign-up came through your server: the email and the time,
// signed with a secret only your server and the hook know.
function pass(email) {
  const ts = Date.now();
  const mac = createHmac("sha256", process.env.SIGNUP_PASS_SECRET).update(`${email}.${ts}`).digest("hex");
  return `${ts}.${mac}`;
}

El hook, como Edge Function. Supabase lo llama antes de crear cualquier usuario, con su email y sus metadatos, y espera hasta 5 segundos. Rechaza un registro por email sin un pase válido de los últimos 5 minutos; nunca llama a Bot Protection, así que, aunque Bot Protection esté lento o caído, el hook no se detiene.

// supabase/functions/before-user-created/index.ts
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";

const hookSecret = Deno.env.get("BEFORE_USER_CREATED_HOOK_SECRET")!.replace("v1,whsec_", "");
const passSecret = new TextEncoder().encode(Deno.env.get("SIGNUP_PASS_SECRET")!);

Deno.serve(async (req) => {
  const payload = await req.text();
  let user;
  try {
    ({ user } = new Webhook(hookSecret).verify(payload, Object.fromEntries(req.headers)));
  } catch {
    return new Response("{}", { status: 401 });
  }
  // Sign-ups with Google, GitHub and the like never pass through your form.
  if (user.app_metadata?.provider !== "email" || (await passOK(user.email, user.user_metadata?.korzen_pass))) {
    return Response.json({});
  }
  return Response.json({ error: { http_code: 403, message: "Please sign up on our site." } }, { status: 403 });
});

// passOK checks the server's signature, made in the last 5 minutes for this email.
async function passOK(email: string, pass: unknown): Promise<boolean> {
  const [ts, mac] = String(pass ?? "").split(".");
  if (!ts || !mac || Date.now() - Number(ts) > 5 * 60_000) return false;
  const key = await crypto.subtle.importKey("raw", passSecret, { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
  const sig = Uint8Array.from(mac.match(/../g) ?? [], (h) => parseInt(h, 16));
  return crypto.subtle.verify("HMAC", key, sig, new TextEncoder().encode(`${email.toLowerCase()}.${ts}`));
}

Despliégalo con supabase functions deploy before-user-created --no-verify-jwt (Supabase llama a los hooks sin token de usuario), define SIGNUP_PASS_SECRET en ella y activa el hook en el dashboard de Supabase en Authentication, Hooks, como hook HTTP que apunta a la función. Guarda el secreto que Supabase muestra ahí como BEFORE_USER_CREATED_HOOK_SECRET.

La IP del visitante

La decisión se toma en tu server action, así que la IP es lo que dice la guía de Next.js: x-forwarded-for en Vercel, la cabecera de tu borde en otros casos.

Actúa según la action

Lee solo action. Con block, responde con el mismo error que la acción muestra cuando falla por cualquier otro motivo, para que un bot no aprenda nada. Con challenge, usa la verificación que ya tienes (un CAPTCHA, un código por email o teléfono); si no tienes ninguna, sigue adelante. La cuenta empieza en monitor mode, donde action es siempre allow: nada cambia para los visitantes hasta que actives el block mode.

Guarda el request_id

Cada respuesta trae un request_id. Guárdalo con lo que la acción creó, en una columna de la cuenta o del pedido, o en una línea de log con su id. Sirve para identificar esa decisión cuando nos preguntes por ella y para averiguar después qué respuesta dejó entrar una cuenta falsa.

Pruébalo en tu máquina

Ejecuta supabase start y next dev. Activa el hook en local en supabase/config.toml y sirve la función:

# supabase/config.toml
[auth.hook.before_user_created]
enabled = true
uri = "http://host.docker.internal:54321/functions/v1/before-user-created"
secrets = "env(BEFORE_USER_CREATED_HOOK_SECRET)"

# then: supabase functions serve before-user-created --no-verify-jwt

Luego abre http://localhost:3000. Las páginas en localhost siempre aceptan el tag y nunca aparecen como dominio. En local la IP es 127.0.0.1 o ::1, y es normal. En Docker en tu máquina, la app ve en su lugar la dirección del bridge, como 172.17.0.1: la página de desafío te devuelve igual a localhost, porque el tag vio tu página allí.

  1. Define KORZEN_API_KEY en .env.local (y SIGNUP_PASS_SECRET en supabase/functions/.env) y abre la página de registro. En las herramientas de desarrollo del navegador, la cookie _kz aparece tras unos segundos.
  2. Regístrate. La decisión aparece en la página Decisiones del panel, con el path que enviaste.
  3. Comprueba el fail open: pon una clave incorrecta y regístrate de nuevo. La llamada responde 401, y el registro pasa.

Errores comunes

  • Solo la server action, sin el hook. Un bot se registra con tu anon key pública, directo en Supabase.
  • Un hook que rechaza todo registro sin pase. Los registros con Google o GitHub nunca pasan por tu formulario: el código de arriba los deja pasar.
  • Un secreto distinto en cada lado, o el email comparado con mayúsculas distintas. Entonces se rechaza todo registro: la action pasa el email a minúsculas, y el hook también.
  • Llamar a /v1/decide desde el navegador. La API key quedaría expuesta a cada visitante; la llamada se hace desde tu servidor.
  • No poner timeout, o dejar que un error detenga el registro. Cualquier respuesta distinta de 200, o la falta de respuesta a tiempo, significa: deja pasar al visitante.