Met Supabase Auth kan de browser een account aanmaken door Supabase rechtstreeks aan te roepen, zodat je server de registratie nooit ziet. Twee onderdelen dichten dat gat: je server registreert de bezoeker, nadat hij Bot Protection heeft geraadpleegd, en een Before User Created hook weigert registraties die niet via je server kwamen. Deze gids gebruikt Next.js met @supabase/ssr; met elke andere server werkt het op dezelfde manier.
De tag
Zoals in de Next.js-gids, in de root layout. De functie decide komt ook uit die gids.
De call
De server action die de bezoeker registreert. Die raadpleegt Bot Protection en geeft Supabase daarna een pas mee: het e-mailadres en het tijdstip, ondertekend met SIGNUP_PASS_SECRET, een willekeurig secret dat je zelf maakt en aan beide kanten meegeeft.
// 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}`; }
De hook, als Edge Function. Supabase roept hem aan voordat het een gebruiker aanmaakt, met het e-mailadres en de metadata van de gebruiker, en wacht maximaal 5 seconden. Hij weigert een registratie per e-mail zonder geldige pas van de laatste 5 minuten; hij roept Bot Protection nooit aan, dus een trage of onbereikbare Bot Protection kan hem niet tegenhouden.
// 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}`)); }
Deploy hem met supabase functions deploy before-user-created --no-verify-jwt (Supabase roept hooks aan zonder token van een gebruiker), zet SIGNUP_PASS_SECRET erop, en zet hem aan in het Supabase-dashboard onder Authentication, Hooks, als HTTP-hook die naar de function wijst. Bewaar het secret dat Supabase daar toont als BEFORE_USER_CREATED_HOOK_SECRET.
Het IP-adres van de bezoeker
De beslissing draait in je server action, dus het IP-adres is wat de Next.js-gids zegt: x-forwarded-for op Vercel, elders de eigen header van je edge.
Voer de actie uit
Lees alleen action. Bij block antwoord je met dezelfde foutmelding die de actie toont als hij om een andere reden mislukt, zodat een bot niets leert. Bij challenge gebruik je de controle die je al hebt (een CAPTCHA, een code per e-mail of telefoon); heb je er geen, ga dan door. Het account begint in monitor mode, waar action altijd allow is: voor bezoekers verandert er niets tot je block mode aanzet.
Bewaar de request_id
Elk antwoord heeft een request_id. Bewaar die bij wat de actie heeft aangemaakt, in een kolom van het account of de bestelling, of in een logregel met hun id. Zo weet je welke beslissing het was als je ons ernaar vraagt, en kun je later nagaan welk antwoord een nepaccount binnenliet.
Test het op je eigen machine
Draai supabase start en next dev. Zet de hook lokaal aan in supabase/config.toml, en serveer de function:
# 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
Open daarna http://localhost:3000. Pagina's op localhost accepteren de tag altijd en staan nooit als domein in de lijst. Lokaal is het IP-adres 127.0.0.1 of ::1, en dat is prima. In Docker op je eigen machine ziet de app in plaats daarvan het adres van de bridge, zoals 172.17.0.1: de challengepagina brengt je toch terug naar localhost, want de tag zag je pagina daar.
- Zet
KORZEN_API_KEYin.env.local(enSIGNUP_PASS_SECRETinsupabase/functions/.env) en open de registratiepagina. In de developer tools van de browser verschijnt na een paar seconden de_kz-cookie. - Registreer je. De beslissing verschijnt op de pagina Beslissingen van het dashboard, onder het pad dat je stuurde.
- Controleer fail-open: zet een verkeerde key en registreer je opnieuw. De call antwoordt met
401, en de registratie gaat door.
Veelgemaakte fouten
- Alleen de server action, zonder de hook. Een bot registreert zich dan met je openbare anon key, rechtstreeks bij Supabase.
- Een hook die elke registratie zonder pas weigert. Registraties met Google of GitHub gaan nooit via je formulier: de code hierboven laat ze door.
- Een ander secret aan elke kant, of een e-mailadres dat in een andere hoofdletterstand wordt vergeleken. Dan wordt elke registratie geweigerd: de action zet het e-mailadres in kleine letters, en de hook ook.
/v1/decideaanroepen vanuit de browser. De API key zou dan bij elke bezoeker terechtkomen; de call hoort op je server.- Geen timeout, of een fout die de registratie tegenhoudt. Elk ander antwoord dan
200, en geen antwoord op tijd, betekent: laat de bezoeker door.