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.
- Définissez
KORZEN_API_KEYdans.env.local(etSIGNUP_PASS_SECRETdanssupabase/functions/.env) et ouvrez la page d'inscription. Dans les outils de développement du navigateur, le cookie_kzapparaît après quelques secondes. - Inscrivez-vous. La décision apparaît sur la page Décisions du tableau de bord, sous le chemin que vous avez envoyé.
- 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/decidedepuis 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.