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.
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
- Open your app's preview and its sign-up page. In the browser's developer tools, the
_kzcookie appears after a few seconds. - Sign up. The decision shows on the dashboard's Decisions page, under
/signup. - Check fail open: set a wrong
KORZEN_API_KEYand sign up again. The call answers401, 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.signUpitself. The function then never runs: search the code for it after Lovable's change. - No
x-korzen-tokenin 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.