Guide · Supabase Auth

Supabase can refuse the row
before it is written.

The Before User Created hook is the only officially documented pre-persistence gate in Supabase Auth, it runs on Free and Pro, and it genuinely blocks. It also has a trap that costs an afternoon: the rejection status in Supabase's own example does not reject, and the failure mode hides your message behind a generic 500.

01Carry the signals

The hook receives no request headers and no side channel. The only carrier is options.data on signUp, which arrives in the payload as user.user_metadata.

// Anywhere you call signUp. options.data becomes user_metadata
// on the hook payload, which is the only carrier the hook can see.
const signals = window.Huma ? window.Huma.debug().features : undefined;

await supabase.auth.signUp({
  email,
  password,
  options: { data: { huma_signals: signals } },
});
This is transport, not a trust boundary. A bot can post whatever blob it likes into options.data. That is fine, because the scoring happens on our side from the signals, not from anything the page asserts. Never put a verdict in there.

02Deploy the hook

# Deploy with JWT verification OFF. A default deploy answers the auth
# server's signature-only POST with 401, which surfaces as a 500 and not
# as a rejection.
supabase functions deploy before-user-created --no-verify-jwt

# Then: Dashboard > Authentication > Hooks > Before User Created
# HTTPS endpoint: https://<ref>.supabase.co/functions/v1/before-user-created

03The function

// supabase/functions/before-user-created/index.ts
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";

// Content-Type is mandatory on the 200, including on the allow path.
const json = (body: unknown) =>
  new Response(JSON.stringify(body), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });

Deno.serve(async (req) => {
  const payload = await req.text();
  const secret = Deno.env
    .get("BEFORE_USER_CREATED_HOOK_SECRET")!
    .replace("v1,whsec_", "");

  let event: {
    user: { email?: string; user_metadata?: Record<string, unknown> };
  };
  try {
    event = new Webhook(secret).verify(
      payload,
      Object.fromEntries(req.headers),
    ) as typeof event;
  } catch {
    // A 401 status here is NOT a rejection: return the error at status 200.
    return json({ error: { http_code: 401, message: "Unauthorized" } });
  }

  const sessionData = event.user.user_metadata?.huma_signals;
  // OAuth, SAML, magic link and anonymous paths can never carry signals,
  // because their metadata is built from the provider's claims. Allow them.
  if (!sessionData) return json({});

  let v;
  try {
    const res = await fetch("https://humaverify.com/api/v1/verify", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${Deno.env.get("HUMA_API_KEY")}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        userId: event.user.email ?? "supabase_signup",
        sessionData,
      }),
      // The hook's own budget is 5s per attempt and it sits inside the
      // signup transaction. Stay well inside it.
      signal: AbortSignal.timeout(3000),
    });
    if (!res.ok) return json({}); // fail open
    v = await res.json();
  } catch {
    return json({});              // fail open
  }

  if (v.agent?.is_agent || v.human === false) {
    // THE LINE THAT MATTERS: reject with 200 and an error body.
    return json({
      error: { http_code: 400, message: "We could not verify this session." },
    });
  }
  return json({});
});

The one that costs an afternoon

Supabase's documented example rejects with status: 400. The auth server's dispatcher maps an HTTP 400 from a hook to 500 unexpected_failure with the message Invalid payload sent to hook, and your own message never reaches the user. Reject with HTTP 200 and an error body. The http_code inside that body is what becomes the status the client sees.

What bites

Not the same thing as the marketplace integration

There is a useHUMA integration that injects HUMA_API_KEY into your Supabase project, which saves you a copy and paste. This hook is about the auth flow itself and works whether or not you installed anything. If you want the key placed for you, the Supabase guide covers that side.

Why this instead of a CAPTCHA

A CAPTCHA charges every legitimate user for the existence of bots that increasingly solve puzzles anyway. This charges nobody: the person types, and the decision happens on your server from what already happened. It also answers a question a CAPTCHA does not, which is whether the session was an AI agent driving a real browser rather than a script. What it does not catch is written down at /limits, before you build on it.

Next.jsWordPressClerkAuth0Auth.js / NextAuthFirebase Auth

Supabase for auth, useHUMA for humanity.

Free plan: 1,000 verifications a month · no card · no expiry · AI-agent verdict included.

Get your API key →

Hook names, statuses and timeouts on this page were checked against Supabase's live documentation and its auth server source on 26 Aug 2026. If something has drifted, tell us at team@humaverify.com and it gets fixed.

Help