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 a non-2xx 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 screens 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.

The browser half

Wherever the check ends up, it needs a token the browser produced. Load the collector and pass the token through the signup call's metadata:

<!-- index.html -->
<script async src="https://maskbreak.com/assets/sentinel.js">
</script>
// src/signup.ts
const token = await window.sentinel?.token();

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

Two things to be clear-eyed about. User metadata is client-supplied and a determined attacker can put whatever they like in it — but the token is encrypted and verified server-side on evaluation, so a forged one fails to decrypt rather than passing. And the token can legitimately be missing: content blockers stop the collector for real users, so the hook has to have an answer for that case rather than treating it as guilt.

Option 1: the Before User Created hook

The hook is an HTTP endpoint you host — an Edge Function is the natural home — that Supabase calls synchronously before writing the user.

// supabase/functions/screen-signup/index.ts
import { createHmac } from "node:crypto";

const SECRET = Deno.env.get("HOOK_SECRET")!;   // from the dashboard
const KEY    = Deno.env.get("MASKBREAK_KEY")!;

Deno.serve(async (req) => {
  const raw = await req.text();

  // Verify this really came from Supabase before trusting a byte
  // of it. The endpoint is public.
  if (!verify(req.headers, raw, SECRET)) {
    return new Response("unauthorized", { status: 401 });
  }

  const { user } = JSON.parse(raw);
  const token = user?.user_metadata?.monocle;

  // No token: an ad blocker or a non-browser client. Common on
  // real traffic, and not evidence of anything.
  if (!token) return new Response("{}", { status: 200 });

  let verdict;
  try {
    const ac = new AbortController();
    // Hard deadline. Supabase is waiting on this, and so is a
    // human looking at a spinner.
    const t = setTimeout(() => ac.abort(), 1200);
    const res = await fetch("https://maskbreak.com/v1/evaluate", {
      method: "POST",
      headers: {
        "Authorization": "Bearer " + KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ token, email: user.email }),
      signal: ac.signal,
    });
    clearTimeout(t);
    verdict = await res.json();
  } catch (_) {
    // Fail open. A detection outage must not stop registration.
    return new Response("{}", { status: 200 });
  }

  if (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" },
    });
  }

  return new Response("{}", { status: 200 });
});

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:

supabase functions deploy screen-signup --no-verify-jwt
supabase secrets set MASKBREAK_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 a timeout, so your own deadline must be comfortably inside it — hence 1200 ms rather than a hopeful 5 s. It only fires for signups that go through GoTrue, so a user you insert with the admin API bypasses it entirely. And blocking 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.

// supabase/functions/signup/index.ts
import { createClient } from "jsr:@supabase/supabase-js@2";

const admin = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,   // server only
);

Deno.serve(async (req) => {
  const { email, password, monocle } = await req.json();
  const verdict = await evaluate(monocle, email);   // as above

  if (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: 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. Leaking it is worse than the fraud you are preventing.

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.

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:

-- 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. Solving farms clear that for a fraction of a cent, 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 actually looks like. Run both: the captcha filters crude automation, the screening call 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.
  • 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.
  • Keep your own deadline well inside Supabase's hook timeout, and fail open on the way out.
  • Block on block only. The hook cannot step a user up, and a plain VPN lands in review.
  • Own the signup in an Edge Function only when you need the control — you inherit confirmation, rate limits and error handling with it.
  • The service role key never leaves function secrets.
  • Database webhooks fire after the row exists. That is enrichment, not prevention.
FAQ

Frequently Asked Questions

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 an AbortController deadline: 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 by commercial solving farms for a fraction of a cent. 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.

Screen the signup before the row exists

One fetch inside your auth hook returns a decision, a score and the reasons behind it. Free tier: 1,000 requests per hour, no credit card.

Try Maskbreak free →