Korzen
Bot Protection
In these docs

Lovable

Bot Protection in a Lovable app on Supabase: the tag in index.html, sign-ups and trials through an Edge Function, and a prompt that adds both.

A Lovable app is a page in the browser and a Supabase project behind it, with no server of its own. Bot Protection fits in two pieces: the tag in the page, and a Supabase Edge Function that asks Bot Protection before it creates the account or starts the trial. The Supabase Auth guide's hook closes the way around it. Lovable writes all of it from the prompt below; the rest of this page is what it writes, so you can read the diff.

The prompt

Paste it into your project's chat. Signed in, this page fills in your own site key. Then add your secret API key to the project's Edge Function secrets as KORZEN_API_KEY when Lovable asks for it, never in the chat.

Prompt
Add Korzen Bot Protection to this app, in monitor mode, failing open.

1. In index.html, inside <head>, add exactly:
   <script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>

2. Create a Supabase Edge Function "signup" (verify_jwt = false) that the sign-up form
   calls instead of supabase.auth.signUp. It imports
   npm:@korzen/bot-protection@2 and npm:@supabase/supabase-js@2, reads the secret
   KORZEN_API_KEY, and for each POST {email, password}:
   - builds the input with fromRequest(req, { ip: ipFrom.vercel, path: "/signup" }) and
     calls client.decide(input). decide returns null on any error or timeout: then go on.
   - if the action is "block" or "challenge", answers 403 with
     {"error": "We could not create your account."} and creates nothing.
   - otherwise calls supabase.auth.signUp with the anon key, passing
     korzen_request_id (the decision's request_id) in options.data, and answers
     what Supabase answered.
   - answers OPTIONS for CORS and allows the headers authorization, x-client-info,
     apikey, content-type and x-korzen-token.

3. In the sign-up form, before calling the function, get the token with
   `await window.korzen?.ready()` and send it as the x-korzen-token header of
   supabase.functions.invoke("signup", ...). Show the function's error as the form's
   error. Never call /v1/decide from the browser.

4. If the app has a free trial, call the same check with path "/trial" in the Edge
   Function that starts it, before the trial is written.

Do not add a CAPTCHA, change the sign-in, or log the API key. Show me the diff.

The tag

In index.html, in the <head>. The tag watches the page and hands its token to the function with each sign-up. Your Lovable address and your own domain are both learnt as your site's domains on the first visits.

<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>

The Edge Function

Supabase runs Edge Functions on Deno, which runs the Node SDK as it is, imported from npm. The function asks Bot Protection, then creates the account with your anon key, as the form did before. Any error, a timeout past 200 ms or a wrong key gives no decision, and the sign-up goes on.

// supabase/functions/signup/index.ts
import { createClient as korzen, fromRequest, ipFrom } from "npm:@korzen/bot-protection@2";
import { createClient } from "npm:@supabase/supabase-js@2";

const bp = korzen({ apiKey: Deno.env.get("KORZEN_API_KEY") ?? "" });
const cors = {
  "access-control-allow-origin": "*",
  "access-control-allow-headers": "authorization, x-client-info, apikey, content-type, x-korzen-token",
};

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") return new Response("ok", { headers: cors });
  const { email, password } = await req.json();
  // The token comes in x-korzen-token; the first X-Forwarded-For entry is the visitor's.
  const d = await bp.decide(fromRequest(req, { ip: ipFrom.vercel, path: "/signup" }));
  if (d?.action === "block" || d?.action === "challenge") {
    return Response.json({ error: "We could not create your account." }, { status: 403, headers: cors });
  }
  const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!);
  const { data, error } = await supabase.auth.signUp({
    email,
    password,
    options: { data: { korzen_request_id: d?.request_id ?? null } },
  });
  return Response.json(error ? { error: error.message } : data, { status: error ? 400 : 200, headers: cors });
});

Deploy it with verify_jwt = false for this function in supabase/config.toml: a visitor who signs up has no session yet. Lovable does both when it writes the function.

The form

The form calls the function in place of supabase.auth.signUp, with the tag's token in a header: the page and the function are on different domains, so the cookie does not travel.

const token = (await window.korzen?.ready()) ?? "";
const { data, error } = await supabase.functions.invoke("signup", {
  body: { email, password },
  headers: { "x-korzen-token": token },
});

The trial

Where the app starts a free trial in an Edge Function, the same call goes first, with the path /trial. The visitor is signed in there, so keep Supabase's JWT check on, and send the token from the page the same way.

const d = await bp.decide(fromRequest(req, { ip: ipFrom.vercel, path: "/trial" }));
if (d?.action === "block" || d?.action === "challenge") {
  return Response.json({ error: "We could not start your trial." }, { status: 403, headers: cors });
}
// start the trial as before, keeping d?.request_id with it

Close the way around it

The anon key is in the page, so a bot can still call Supabase's sign-up directly. The Supabase Auth guide's Before User Created hook refuses sign-ups that did not come through your function: give the function the pass that guide's server action makes, with the Web Crypto API in place of node:crypto. Add it once monitor mode shows the decisions you expect.

The visitor's IP

In an Edge Function the visitor's address is the first X-Forwarded-For entry, as Supabase's own examples read it; ipFrom.vercel reads that one. Check it once: send a sign-up with an X-Forwarded-For of your own and look at the decision's IP on the dashboard. If it shows the address you wrote, tell us before turning on block mode.

Test it

  1. Open your app's preview and its sign-up page. In the browser's developer tools, the _kz cookie appears after a few seconds.
  2. Sign up. The decision shows on the dashboard's Decisions page, under /signup.
  3. Check fail open: set a wrong KORZEN_API_KEY and sign up again. The call answers 401, and the sign-up goes through.

The account starts in monitor mode, where action is always allow: nothing changes for visitors until you turn on block mode.

Common mistakes

  • The API key in the page or in the chat. It belongs in the function's secrets alone; the page has only the site key.
  • The form still calling supabase.auth.signUp itself. The function then never runs: search the code for it after Lovable's change.
  • No x-korzen-token in the CORS headers. The browser then refuses the call before it leaves.
  • An error from the call that stops the sign-up. No decision means: let the visitor through.