Korzen
Bot Protection

Supabase Auth

Bot Protection mit Supabase Auth: Registrierungen über Ihren Server und ein Before-User-Created-Hook, der die ablehnt, die ihn umgangen haben.

Mit Supabase Auth kann der Browser ein Konto anlegen, indem er Supabase direkt aufruft, sodass Ihr Server die Registrierung nie sieht. Zwei Teile schließen das: Ihr Server registriert den Besucher, nachdem er Bot Protection gefragt hat, und ein Before User Created-Hook lehnt Registrierungen ab, die nicht über ihn kamen. Diese Anleitung nutzt Next.js mit @supabase/ssr; jeder Server funktioniert genauso.

Der Tag

Wie in der Next.js-Anleitung, im Root-Layout. Die Funktion decide stammt ebenfalls aus dieser Anleitung.

Der Aufruf

Die Server Action, die den Besucher registriert. Sie fragt Bot Protection und übergibt Supabase dann einen Pass: die E-Mail und die Zeit, signiert mit SIGNUP_PASS_SECRET, einem zufälligen Secret, das Sie erzeugen und beiden Seiten geben.

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

Der Hook, als Edge Function. Supabase ruft ihn auf, bevor es einen Nutzer anlegt, mit E-Mail und Metadaten des Nutzers, und wartet bis zu 5 Sekunden. Er lehnt eine E-Mail-Registrierung ohne gültigen Pass aus den letzten 5 Minuten ab; er ruft Bot Protection nie auf, sodass ein langsames oder ausgefallenes Bot Protection ihn nicht stoppen kann.

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

Deployen Sie ihn mit supabase functions deploy before-user-created --no-verify-jwt (Supabase ruft Hooks ohne Nutzer-Token auf), setzen Sie SIGNUP_PASS_SECRET darauf und schalten Sie ihn im Supabase-Dashboard unter Authentication, Hooks ein, als HTTP-Hook, der auf die Funktion zeigt. Bewahren Sie das Secret, das Supabase dort zeigt, als BEFORE_USER_CREATED_HOOK_SECRET auf.

Die IP des Besuchers

Die Entscheidung läuft in Ihrer Server Action, also ist die IP die, die die Next.js-Anleitung nennt: x-forwarded-for auf Vercel, anderswo der eigene Header Ihres Edge.

Die Aktion anwenden

Lesen Sie nur action. Bei block antworten Sie mit demselben Fehler, den die Aktion zeigt, wenn sie aus einem anderen Grund scheitert, damit ein Bot nichts lernt. Bei challenge nutzen Sie die Prüfung, die Sie schon haben (ein CAPTCHA, ein Code per E-Mail oder Telefon); ohne eine machen Sie weiter. Das Konto beginnt im Monitor-Modus, in dem action immer allow ist: Für Besucher ändert sich nichts, bis Sie den Block-Modus einschalten.

Die request_id aufbewahren

Jede Antwort hat eine request_id. Bewahren Sie sie bei dem auf, was die Aktion erstellt hat, in einer Spalte des Kontos oder der Bestellung, oder in einer Logzeile mit deren ID. Sie benennt diese Entscheidung, wenn Sie uns dazu etwas fragen, und lässt Sie später herausfinden, welche Antwort ein Fake-Konto durchgelassen hat.

Auf Ihrem Rechner testen

Führen Sie supabase start und next dev aus. Schalten Sie den Hook lokal in supabase/config.toml ein und starten Sie die Funktion:

# 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

Öffnen Sie dann http://localhost:3000. Seiten auf localhost akzeptieren den Tag immer und erscheinen nie als Domain. Lokal ist die IP 127.0.0.1 oder ::1, und das ist in Ordnung. Läuft die App auf Ihrem Rechner in Docker, sieht sie stattdessen die Adresse der Bridge, etwa 172.17.0.1: Die Challenge-Seite bringt Sie trotzdem zu localhost zurück, denn der Tag hat Ihre Seite dort gesehen.

  1. Setzen Sie KORZEN_API_KEY in .env.local (und SIGNUP_PASS_SECRET in supabase/functions/.env) und öffnen Sie die Registrierungsseite. In den Entwicklertools des Browsers erscheint nach ein paar Sekunden das _kz-Cookie.
  2. Registrieren Sie sich. Die Entscheidung erscheint auf der Seite Entscheidungen des Dashboards, unter dem Pfad, den Sie gesendet haben.
  3. Prüfen Sie fail-open: Setzen Sie einen falschen Schlüssel und registrieren Sie sich erneut. Der Aufruf antwortet 401, und die Registrierung geht durch.

Häufige Fehler

  • Nur die Server Action, ohne den Hook. Ein Bot registriert sich dann mit Ihrem öffentlichen Anon Key direkt bei Supabase.
  • Der Hook lehnt jede Registrierung ohne Pass ab. Registrierungen mit Google oder GitHub gehen nie über Ihr Formular: Der Code oben lässt sie durch.
  • Ein anderes Secret auf jeder Seite, oder eine E-Mail, die in anderer Schreibweise verglichen wird. Dann wird jede Registrierung abgelehnt: Die Action schreibt die E-Mail klein, und der Hook ebenso.
  • /v1/decide aus dem Browser aufrufen. Der API key würde jeden Besucher erreichen; der Aufruf gehört auf Ihren Server.
  • Kein Timeout, oder ein Fehler, der die Registrierung stoppt. Jede andere Antwort als 200, und keine Antwort rechtzeitig, bedeutet: den Besucher durchlassen.