Blocking Fake Signups in Supabase When You Do Not Own the Signup

auth.signUp runs on Supabase infrastructure, not yours — so the fraud check has to go somewhere else. Three places it can go, and which one to pick.

Red braided ropes hang from rows of polished brass stanchions, waiting to be set out
Photo: Stanchions and velvet rope - 37th G8 summit in Deauville 002 by Guillaume Paumier, CC BY 3.0, cropped. Via Wikimedia Commons.
On this page
  1. The three options, ranked
  2. Step 1: Add the script to the page with your form
  3. Step 2: Send the check from your server (an Edge Function)
  4. Step 3: Submit your form once
  5. What to do with the review band
  6. This is not the same thing as Supabase's captcha
  7. The short version

Every framework guide about screening signups starts from the same assumption: you own the endpoint, so you put a check in front of it. Supabase breaks that assumption. supabase.auth.signUp() goes from the browser straight to Supabase's own auth service. There is no server of yours in the path, so there is nothing to wrap.

That leaves three places the check can actually live, and they are not equivalent. One runs before the user exists, one runs after, and one moves the signup onto infrastructure you control. Picking the wrong one is how teams end up deleting fraudulent accounts on a schedule instead of never creating them.

The three options, ranked

1. A Before User Created auth hook. Supabase calls your endpoint before it writes the user, and an error response aborts the signup. This is the only option where the fraudulent account never exists, and it is the right default.

2. An Edge Function that owns the signup. The client calls your function, the function checks and then creates the user with the service role key. More control, more code, and you inherit responsibility for everything the built-in signup handles: rate limits, email confirmation, error messages.

3. A database webhook after the fact. Fires after the row is written. Useful for enrichment and analytics, wrong as a gate: by the time it runs, the account exists, the confirmation email has gone out, and you are cleaning up rather than preventing.

The rest of this walks through the first, then the second for the cases the hook cannot cover, in the three steps the Maskbreak console lists: add the script to the page with your form, send the check from your server (here, an Edge Function), and submit your form once. It starts in watch mode, where every check is logged and nothing is refused.

Step 1: Add the script to the page with your form

Wherever the check ends up, it needs evidence the visitor's browser produced. Load the script on the page with your signup form:

html
<!-- index.html -->
<script async src="https://maskbreak.com/assets/sentinel.js"></script>

A Supabase signup is a JavaScript call, so ask the script for the evidence when the visitor submits and pass it through the signup call's metadata. Sentinel.collect() resolves to { token, fingerprintEventId, tz } and waits at most five seconds for the browser check. Check that window.Sentinel exists, and never hold the signup back when it does not: a content blocker or a strict CSP removes it for real visitors.

typescript
// src/signup.ts
const evidence = window.Sentinel
  ? await window.Sentinel.collect().catch(() => null)
  : null;                              // script missing or blocked: sign up anyway

const { data, error } = await supabase.auth.signUp({
  email,
  password,
  options: {
    // Arrives on the hook payload as user.user_metadata.maskbreak.
    data: {
      maskbreak: {
        token: evidence?.token ?? null,
        fingerprintEventId: evidence?.fingerprintEventId ?? null,
        tz: evidence?.tz ?? null,
      },
    },
  },
});

Two things to be clear-eyed about. User metadata is client-supplied, and a determined attacker can put whatever they like in it. The token is verified server-side when it is evaluated, so a forged one cannot pass as a clean reading: Maskbreak answers it with an error, which the hook below treats like missing evidence. And the evidence can legitimately be missing: content blockers stop the script for real users, so the hook has to have an answer for that case rather than treating it as guilt. The values stay in the user's metadata after signup; they are opaque, and you can clear them later with the admin API if you would rather not keep them.

If your site sends a Content-Security-Policy, add https://maskbreak.com, https://mcl.maskbreak.com, https://*.mcl.maskbreak.com, https://mesu.maskbreak.com and 'wasm-unsafe-eval' to script-src, the mcl hosts (https and wss) and mesu to connect-src, and https://mesu.maskbreak.com blob: to worker-src.

Try it

Wire it into your own app: a free key returns decision, risk_score and reasons for every visit, 10,000 checks a month on the Free plan, no card.

Get an API key

Step 2: Send the check from your server (an Edge Function)

Both options share one helper. It sends whatever evidence arrived, including none: in watch mode a check without a token or a browser check id still has to reach Maskbreak, because that is how the console tells you which half is missing. It never throws, and it logs one line per check to the function's logs.

typescript
// supabase/functions/_shared/maskbreak.ts
export const MODE = Deno.env.get("MASKBREAK_MODE") ?? "watch"; // "watch" | "enforce"

type Evidence = { token?: string | null; fingerprintEventId?: string | null; tz?: string | null };

// Returns the verdict, or null when there is none. Never throws.
export async function checkVisitor(ev: Evidence | null | undefined, email?: string) {
  let result: Record<string, any> | null = null;
  try {
    const res = await fetch("https://maskbreak.com/v1/evaluate", {
      method: "POST",
      headers: {
        Authorization: "Bearer " + Deno.env.get("MASKBREAK_API_KEY"),
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        token: ev?.token || "",
        fingerprintEventId: ev?.fingerprintEventId || undefined,
        tz: ev?.tz || undefined,
        email: typeof email === "string" ? email : undefined,
      }),
      // Supabase is waiting on this, and so is a person at a spinner.
      signal: AbortSignal.timeout(2500),
    });
    if (res.ok) result = await res.json();
    else console.warn("[maskbreak] HTTP", res.status);    // 401: the key, 400: the body
  } catch (err) {
    console.warn("[maskbreak] unavailable:", (err as Error).name);
  }
  // One line per check. Never log the key, the token or form data.
  console.log("[maskbreak]", JSON.stringify({
    mode: MODE,
    decision: result?.decision ?? null,
    reasons: result?.reasons ?? null,
    device_evidence: result?.device_evidence ?? null,
    degraded: result ? !!result.degraded : null,
  }));
  return result;
}

Option 1: the Before User Created hook

The hook is an HTTP endpoint you host, an Edge Function here, that Supabase calls synchronously before writing the user. Supabase signs each call; verify the signature before trusting a byte of it.

typescript
// supabase/functions/screen-signup/index.ts
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";
import { MODE, checkVisitor } from "../_shared/maskbreak.ts";

const hook = new Webhook(Deno.env.get("HOOK_SECRET")!.replace("v1,whsec_", ""));

Deno.serve(async (req) => {
  const raw = await req.text();
  let payload;
  try {
    payload = hook.verify(raw, Object.fromEntries(req.headers));
  } catch {
    return new Response("unauthorized", { status: 401 });   // not from Supabase
  }

  const user = payload.user ?? {};
  const verdict = await checkVisitor(user.user_metadata?.maskbreak, user.email);

  if (MODE === "enforce" && verdict?.decision === "block") {
    // Supabase surfaces this to the client and aborts the signup.
    return new Response(JSON.stringify({
      error: { http_code: 403, message: "Sign-up unavailable from this connection." },
    }), { status: 403, headers: { "Content-Type": "application/json" } });
  }
  // Watch mode, no verdict (outage), allow or review: the signup goes ahead.
  return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
});

Register it in the dashboard under Authentication, Hooks, pointing at the function URL, and copy the signing secret into HOOK_SECRET. Deploy with --no-verify-jwt, because Supabase calls the hook with its own signature rather than a user JWT:

bash
supabase functions deploy screen-signup --no-verify-jwt
supabase secrets set MASKBREAK_API_KEY=sk_live_... HOOK_SECRET=v1,whsec_...

--no-verify-jwt makes the endpoint publicly callable, which is exactly why the signature check at the top is not optional. Without it, anyone who finds the URL can post arbitrary payloads to your screening logic.

Three constraints worth knowing before you commit to this route. The hook is synchronous and Supabase enforces its own timeout, so keep your deadline comfortably inside it. It only fires for signups that go through Supabase Auth, so a user you insert with the admin API bypasses it entirely. And refusing on review rather than block is a mistake here: a plain VPN lands in review, and this path has no way to ask the user for a second factor.

Option 2: an Edge Function that owns the signup

When you want more than a yes or no (a step-up for the middle band, your own rate limiting, a row in your own table), move the signup into a function and call it instead of auth.signUp. The client posts { email, password, ...evidence }, with evidence from Sentinel.collect() as above.

typescript
// supabase/functions/signup/index.ts
import { createClient } from "jsr:@supabase/supabase-js@2";
import { MODE, checkVisitor } from "../_shared/maskbreak.ts";

const admin = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,   // server only
);
const json = (body: unknown, status: number) =>
  new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });

Deno.serve(async (req) => {
  const { email, password, token, fingerprintEventId, tz } = await req.json();
  const verdict = await checkVisitor({ token, fingerprintEventId, tz }, email);

  if (MODE === "enforce" && verdict?.decision === "block") {
    return json({ error: "refused" }, 403);
  }

  const { data, error } = await admin.auth.admin.createUser({
    email,
    password,
    email_confirm: false,
    user_metadata: {
      risk_score: verdict?.risk_score ?? null,
      needs_review: MODE === "enforce" && verdict?.decision === "review",
    },
  });

  if (error) return json({ error: error.message }, 400);
  return json({ user_id: data.user.id }, 200);
});

The service role key bypasses row-level security completely. It belongs in function secrets and nowhere else: never in a VITE_ or NEXT_PUBLIC_ variable, never in the client bundle.

Note what you have taken on by choosing this path: the built-in signup handles email confirmation, password strength, captcha integration, rate limiting and consistent error codes, and every one of those is now yours to reproduce. Choose it when you genuinely need the control, not because the hook felt indirect.

Step 3: Submit your form once

Deploy, open your app in an ordinary browser, wait a moment, and sign up once. The first check then appears in the Maskbreak console (if half of it did not arrive, the console says which half), and the function's logs show one [maskbreak] line. degraded: true means no network token arrived (the script was blocked, or collect() was not called); device_evidence: "not_sent" means no browser check id arrived; "ok" with degraded: false is a complete check. When the lines look right after a few days, set MASKBREAK_MODE=enforce with supabase secrets set.

What to do with the review band

The value in screening is not only refusing the obvious. Persisting the verdict lets you treat the middle band as what it is: not enough evidence to refuse, and a good reason to ask for more.

sql
-- Everything the app reads goes through this, not through
-- auth.users directly.
create table public.profiles (
  id            uuid primary key references auth.users(id)
                on delete cascade,
  risk_score    int,
  needs_review  boolean default false,
  created_at    timestamptz default now()
);

alter table public.profiles enable row level security;

-- A flagged account can read itself but cannot act. Enforce that
-- once, in the database, rather than in every client.
create policy "own profile" on public.profiles
  for select using (auth.uid() = id);

Then gate the actions that matter (posting, withdrawing, inviting) on needs_review being false, and clear the flag after an email or phone confirmation. That is a far better outcome than a block for a customer who happens to use a VPN, and it costs an abuser real effort. More on band-by-band policy in risk score thresholds.

This is not the same thing as Supabase's captcha

Supabase has built-in captcha support for auth endpoints, and it is worth enabling; it is also a different measurement. A captcha asks whether something at the other end can solve a puzzle. Commercial solving services clear that cheaply, and it says nothing about whether the connection is a residential proxy or the browser an antidetect profile with a spoofed canvas, which is what a professional multi-accounting operation looks like. Run both: the captcha filters crude automation, and the check answers questions the captcha cannot ask. There is more in bot detection without CAPTCHA.

The short version

  • You do not own auth.signUp, so there is no middleware slot. Pick a hook, a function, or a webhook.
  • Collect { token, fingerprintEventId, tz } with Sentinel.collect() and send all three, every time, even when one is missing.
  • The Before User Created hook is the default: the fraudulent account never exists.
  • Verify the hook signature. --no-verify-jwt makes the endpoint public, and that check is what protects it.
  • Watch first. In enforce mode refuse on block only: the hook cannot step a user up, and a plain VPN lands in review.
  • The service role key never leaves function secrets.
  • Database webhooks fire after the row exists. That is enrichment, not prevention.

Questions people ask

How do I block fake signups in Supabase Auth?
Use a Before User Created auth hook. Supabase calls your endpoint synchronously before writing the user, and a non-2xx response aborts the signup, so the fraudulent account is never created at all. Host the hook as an Edge Function, verify the signing secret on every request, and keep your own timeout comfortably inside the hook timeout so a slow check does not stall registration.
Can I run a fraud check inside a Supabase Edge Function?
Yes. Edge Functions run Deno with normal fetch, so calling the API is an ordinary HTTPS request with the key read from function secrets. Always attach a deadline (AbortSignal.timeout): without one, a slow upstream holds the function until the platform kills it, and the user sees a hung form rather than a signup.
Is a database webhook good enough for blocking signups?
No. Webhooks fire after the row is committed, so the account already exists and the confirmation email has already gone out. That makes them useful for enrichment, scoring and analytics, and unsuitable as a gate. If the requirement is that the account is never created, the check has to run before the write, which means an auth hook.
Does Supabase captcha protection already cover this?
It covers a different question. A captcha establishes that something at the other end could solve a puzzle, which stops crude automation and is cleared cheaply by commercial solving services. It cannot tell you the connection came through a residential proxy or that the browser is an antidetect profile with a spoofed fingerprint. Enable both: they fail in different places.

Paste it in, then watch the verdicts

The public sk_test_sandbox key returns the documented allow, review and block shapes with no account, so the failure path is testable before you go live. SDKs for Node, Python and PHP, or plain HTTP. The API reference and the Free plan cover the rest.

Get started freeRead the API docs