Korzen
Bot Protection

Supabase Auth

Bot Protection com Supabase Auth: cadastros pelo seu servidor e um hook Before User Created que recusa os que pularam esse passo.

Com o Supabase Auth, o navegador consegue criar uma conta chamando o Supabase direto, e o seu servidor nunca vê o cadastro. Duas peças fecham essa brecha: o seu servidor faz o cadastro, depois de perguntar ao Bot Protection, e um hook Before User Created recusa cadastros que não passaram por ele. Este guia usa Next.js com @supabase/ssr; qualquer servidor funciona do mesmo jeito.

A tag

Como no guia de Next.js, no root layout. A função decide também é a daquele guia.

A chamada

A server action que faz o cadastro. Ela pergunta ao Bot Protection e entrega ao Supabase um passe: o email e o horário, assinados com SIGNUP_PASS_SECRET, um segredo aleatório que você gera e configura nos dois 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}`;
}

O hook, como Edge Function. O Supabase o chama antes de criar qualquer usuário, com o email e os metadados, e espera até 5 segundos. Ele recusa um cadastro por email sem um passe válido feito nos últimos 5 minutos; nunca chama o Bot Protection, então, se o Bot Protection estiver lento ou fora do ar, o hook não trava.

// 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}`));
}

Publique com supabase functions deploy before-user-created --no-verify-jwt (o Supabase chama hooks sem token de usuário), defina SIGNUP_PASS_SECRET nela e ligue o hook no dashboard do Supabase em Authentication, Hooks, como hook HTTP apontando para a função. Guarde o segredo que o Supabase mostra ali como BEFORE_USER_CREATED_HOOK_SECRET.

O IP do visitante

A decisão roda na sua server action, então o IP é o que o guia de Next.js diz: x-forwarded-for na Vercel, o cabeçalho da sua borda nos outros casos.

Siga a action

Leia só a action. Em block, responda com o mesmo erro que a ação mostra quando falha por qualquer outro motivo, para o bot não aprender nada. Em challenge, use a verificação que você já tem (um CAPTCHA, um código por email ou telefone); sem nenhuma, siga em frente. A conta começa em monitor mode, em que a action é sempre allow: nada muda para os visitantes até você ligar o block mode.

Guarde o request_id

Toda resposta tem um request_id. Guarde-o junto do que a ação criou, numa coluna da conta ou do pedido, ou numa linha de log com o id deles. Com ele, você acha a decisão quando falar com a gente e descobre depois qual resposta deixou passar uma conta falsa.

Teste na sua máquina

Rode supabase start e next dev. Ligue o hook localmente em supabase/config.toml e sirva a função:

# 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

Depois abra http://localhost:3000. Páginas em localhost sempre aceitam a tag e nunca entram na lista de domínios. Localmente o IP é 127.0.0.1 ou ::1, e não tem problema. No Docker na sua máquina, o app vê em vez disso o endereço da bridge, como 172.17.0.1: a página de desafio ainda traz você de volta para localhost, porque a tag viu sua página lá.

  1. Defina KORZEN_API_KEY em .env.local (e SIGNUP_PASS_SECRET em supabase/functions/.env) e abra a página de cadastro. Nas ferramentas de desenvolvedor do navegador, o cookie _kz aparece depois de alguns segundos.
  2. Cadastre-se. A decisão aparece na página Decisões do painel, com o path que você enviou.
  3. Confira o fail open: coloque uma chave errada e cadastre-se de novo. A chamada responde 401, e o cadastro passa.

Erros comuns

  • Só a server action, sem o hook. O bot se cadastra com a sua anon key pública, direto no Supabase.
  • Um hook que recusa todo cadastro sem passe. Cadastros com Google ou GitHub nunca passam pelo seu formulário: o código acima os deixa passar.
  • Um segredo diferente em cada lado, ou o email comparado com maiúsculas diferentes. Aí todo cadastro é recusado: a action põe o email em minúsculas, e o hook também.
  • Chamar /v1/decide do navegador. A API key ficaria exposta para todo visitante; a chamada sai do seu servidor.
  • Sem timeout, ou um erro que trava o cadastro. Se a resposta não for 200, ou não chegar a tempo, deixe o visitante passar.