Korzen
Bot Protection

Supabase Auth

Bot Protection avec Supabase Auth : les inscriptions via votre serveur, et un hook Before User Created qui refuse celles qui l'ont contourné.

Avec Supabase Auth, le navigateur peut créer un compte en appelant Supabase directement, donc votre serveur ne voit jamais l'inscription. Deux pièces ferment cette porte : votre serveur inscrit le visiteur, après avoir interrogé Bot Protection, et un hook Before User Created refuse les inscriptions qui ne sont pas passées par lui. Ce guide utilise Next.js avec @supabase/ssr ; tout serveur fonctionne de la même façon.

Le tag

Comme dans le guide Next.js, dans le layout racine. La fonction decide vient aussi de ce guide.

L'appel

La server action qui inscrit le visiteur. Elle interroge Bot Protection, puis remet à Supabase un laissez-passer : l'e-mail et l'heure, signés avec SIGNUP_PASS_SECRET, un secret aléatoire que vous créez et donnez aux deux côtés.

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

Le hook, en Edge Function. Supabase l'appelle avant de créer tout utilisateur, avec l'e-mail et les métadonnées de l'utilisateur, et attend jusqu'à 5 secondes. Il refuse une inscription par e-mail sans laissez-passer valide émis dans les 5 dernières minutes ; il n'appelle jamais Bot Protection, donc un Bot Protection lent ou indisponible ne peut pas le bloquer.

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

Déployez-le avec supabase functions deploy before-user-created --no-verify-jwt (Supabase appelle les hooks sans token d'utilisateur), définissez SIGNUP_PASS_SECRET dessus, et activez-le dans le tableau de bord Supabase sous Authentication, Hooks, comme un hook HTTP pointant vers la fonction. Gardez le secret que Supabase y affiche comme BEFORE_USER_CREATED_HOOK_SECRET.

L'IP du visiteur

La décision s'exécute dans votre server action, donc l'IP est celle qu'indique le guide Next.js : x-forwarded-for sur Vercel, ailleurs l'en-tête propre à votre edge.

Appliquer l'action

Ne lisez que action. Avec block, répondez avec la même erreur que l'action affiche quand elle échoue pour toute autre raison, pour qu'un bot n'apprenne rien. Avec challenge, utilisez la vérification que vous avez déjà (un CAPTCHA, un code par e-mail ou téléphone) ; sans aucune, continuez. Le compte commence en mode monitor, où action est toujours allow : rien ne change pour les visiteurs tant que vous n'activez pas le mode block.

Garder le request_id

Chaque réponse a un request_id. Gardez-le avec ce que l'action a créé, dans une colonne du compte ou de la commande, ou dans une ligne de log avec leur identifiant. Il désigne cette décision quand vous nous interrogez à son sujet, et vous permet de retrouver plus tard quelle réponse a laissé entrer un faux compte.

Tester sur votre machine

Lancez supabase start et next dev. Activez le hook en local dans supabase/config.toml, et servez la fonction :

# 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

Ouvrez ensuite http://localhost:3000. Les pages sur localhost acceptent toujours le tag et n'apparaissent jamais comme domaine. En local, l'IP est 127.0.0.1 ou ::1, et c'est très bien. Dans Docker sur votre machine, l'app voit à la place l'adresse du bridge, comme 172.17.0.1 : la page de challenge vous ramène quand même sur localhost, puisque le tag y a vu votre page.

  1. Définissez KORZEN_API_KEY dans .env.local (et SIGNUP_PASS_SECRET dans supabase/functions/.env) et ouvrez la page d'inscription. Dans les outils de développement du navigateur, le cookie _kz apparaît après quelques secondes.
  2. Inscrivez-vous. La décision apparaît sur la page Décisions du tableau de bord, sous le chemin que vous avez envoyé.
  3. Vérifiez le fail-open : mettez une mauvaise clé et inscrivez-vous à nouveau. L'appel répond 401, et l'inscription passe.

Erreurs fréquentes

  • Seulement la server action, sans le hook. Un bot s'inscrit alors avec votre clé anon publique, directement auprès de Supabase.
  • Le hook qui refuse toute inscription sans laissez-passer. Les inscriptions avec Google ou GitHub ne passent jamais par votre formulaire : le code ci-dessus les laisse passer.
  • Un secret différent de chaque côté, ou un e-mail comparé avec une casse différente. Chaque inscription est alors refusée : l'action met l'e-mail en minuscules, et le hook aussi.
  • Appeler /v1/decide depuis le navigateur. L'API key atteindrait chaque visiteur ; l'appel a sa place sur votre serveur.
  • Pas de timeout, ou une erreur qui bloque l'inscription. Toute réponse autre que 200, ou l'absence de réponse à temps, signifie : laissez passer le visiteur.