Korzen
Bot Protection

Supabase Auth

Bot Protection con Supabase Auth: le registrazioni passano dal tuo server, e un hook Before User Created rifiuta quelle che lo hanno saltato.

Con Supabase Auth, il browser può creare un account chiamando direttamente Supabase, quindi il tuo server non vede mai la registrazione. Due pezzi chiudono questa porta: il tuo server registra il visitatore, dopo aver chiesto a Bot Protection, e un hook Before User Created rifiuta le registrazioni che non sono passate da lì. Questa guida usa Next.js con @supabase/ssr; qualsiasi server funziona allo stesso modo.

Il tag

Come nella guida Next.js, nel root layout. Anche la funzione decide è quella della guida.

La chiamata

La server action che registra il visitatore. Chiede a Bot Protection, poi consegna a Supabase un lasciapassare: l'email e l'ora, firmate con SIGNUP_PASS_SECRET, un secret casuale che generi tu e dai a entrambe le parti.

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

L'hook, come Edge Function. Supabase lo chiama prima di creare qualsiasi utente, con l'email e i metadata dell'utente, e aspetta fino a 5 secondi. Rifiuta una registrazione via email senza un lasciapassare valido creato negli ultimi 5 minuti; non chiama mai Bot Protection, quindi se Bot Protection è lento o non risponde non può bloccarlo.

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

Fai il deploy con supabase functions deploy before-user-created --no-verify-jwt (Supabase chiama gli hook senza il token di un utente), imposta SIGNUP_PASS_SECRET sulla funzione e attivala nella dashboard di Supabase in Authentication, Hooks, come HTTP hook che punta alla funzione. Conserva il secret che Supabase mostra lì come BEFORE_USER_CREATED_HOOK_SECRET.

L'IP del visitatore

La decisione gira nella tua server action, quindi per l'IP vale quanto dice la guida Next.js: x-forwarded-for su Vercel, altrove l'header del tuo edge.

Applica l'azione

Leggi solo action. Su block, rispondi con lo stesso errore che l'azione mostra quando fallisce per qualsiasi altro motivo, così il bot non impara niente. Su challenge, usa la verifica che hai già (un CAPTCHA, un codice via email o telefono); se non ne hai, prosegui. L'account parte in monitor mode, dove action è sempre allow: per i visitatori non cambia niente finché non attivi il block mode.

Conserva il request_id

Ogni risposta ha un request_id. Conservalo insieme a ciò che l'azione ha creato, in una colonna dell'account o dell'ordine, o in una riga di log con il loro id. Identifica quella decisione quando ci chiedi informazioni, e ti permette di scoprire in seguito quale risposta ha fatto entrare un account falso.

Provalo sulla tua macchina

Avvia supabase start e next dev. Attiva l'hook in locale in supabase/config.toml e servi la funzione:

# 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

Poi apri http://localhost:3000. Le pagine su localhost accettano sempre il tag e non compaiono mai tra i domini. In locale l'IP è 127.0.0.1 o ::1, e va bene così. In Docker sulla tua macchina, l'app vede invece l'indirizzo del bridge, come 172.17.0.1: la pagina di challenge ti riporta comunque su localhost, perché il tag ha visto lì la tua pagina.

  1. Imposta KORZEN_API_KEY in .env.local (e SIGNUP_PASS_SECRET in supabase/functions/.env) e apri la pagina di registrazione. Negli strumenti per sviluppatori del browser, il cookie _kz compare dopo qualche secondo.
  2. Registrati. La decisione compare nella pagina Decisioni della dashboard, sotto il path che hai inviato.
  3. Verifica il fail-open: imposta una chiave sbagliata e registrati di nuovo. La chiamata risponde 401, e la registrazione va a buon fine.

Errori comuni

  • Solo la server action, senza l'hook. Un bot allora si registra con la tua anon key pubblica, direttamente su Supabase.
  • L'hook che rifiuta ogni registrazione senza lasciapassare. Le registrazioni con Google o GitHub non passano mai dal tuo form: il codice qui sopra le lascia passare.
  • Un secret diverso su ciascun lato, o un'email confrontata con maiuscole e minuscole diverse. Ogni registrazione viene allora rifiutata: l'action porta l'email in minuscolo, e l'hook anche.
  • Chiamare /v1/decide dal browser. L'API key arriverebbe a ogni visitatore; la chiamata va fatta dal tuo server.
  • Nessun timeout, o un errore che blocca la registrazione. Qualsiasi risposta diversa da 200, o nessuna risposta in tempo, significa: lascia passare il visitatore.