Integrations

How to block fake signups in Supabase Auth

Supabase Auth makes signups easy, which also makes them easy to abuse. To block fake signups, run a check on your server right before supabase.auth.signUp(), and store the result where your users cannot change it.

Before you start

  1. Create a free account at signupvet.com/signup (250 checks a month, no card) and confirm your email.
  2. Create an API key in the dashboard.
  3. Add it to your server environment, never to browser code: SIGNUP_API_KEY=sv_live_...

For step 3 below you also need your project's service role key as SUPABASE_SERVICE_ROLE_KEY on the server. Never use it in the browser.

1. Move the signup to your server

Many apps call supabase.auth.signUp() directly from the browser. A check there can be skipped, so move the call into a server route (or an Edge Function) that runs the check first. This guide uses a Next.js route handler; the idea is the same in any backend.

2. Add the helper

The same small helper as in the Next.js guide: one API call with a 2 second timeout that fails open, so your signup never breaks.

lib/signup-check.ts

// lib/signup-check.ts: server-only SignupVet check (fails open on errors and timeouts).
import "server-only";

export type SignupCheck = {
  recommendation: "allow" | "review" | "block";
  risk_score: number | null;
  reason_codes: string[];
  failed_open?: boolean;
};

const BASE_URL = process.env.SIGNUPVET_URL ?? "https://www.signupvet.com";
const FAIL_OPEN: SignupCheck = { recommendation: "allow", risk_score: null, reason_codes: [], failed_open: true };

export async function checkSignup(signup: {
  email?: string;
  phone?: string;
  ip?: string;
  userAgent?: string;
  timezone?: string;
}): Promise<SignupCheck> {
  try {
    const res = await fetch(`${BASE_URL}/score`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.SIGNUP_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        email: signup.email,
        phone: signup.phone,
        ip: signup.ip,
        context: { user_agent: signup.userAgent, timezone: signup.timezone },
      }),
      signal: AbortSignal.timeout(2000),
      cache: "no-store",
    });
    return res.ok ? ((await res.json()) as SignupCheck) : FAIL_OPEN;
  } catch {
    return FAIL_OPEN;
  }
}

/** Visitor IP on Vercel and most proxies: the first entry of x-forwarded-for. */
export function clientIp(headers: Headers): string | undefined {
  return headers.get("x-forwarded-for")?.split(",")[0]?.trim() || headers.get("x-real-ip") || undefined;
}

3. Check, sign up, store the result

app/api/signup/route.ts

// app/api/signup/route.ts (Next.js App Router + Supabase Auth)
//
// Check first, then call supabase.auth.signUp() on the server. If your signup form
// calls Supabase directly from the browser today, move it behind this route.
import { createClient } from "@supabase/supabase-js";
import { checkSignup, clientIp } from "@/lib/signup-check"; // same file as in ../nextjs/lib

const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
// Server-only admin client: never expose the service role key to the browser.
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);

export async function POST(req: Request) {
  const { email, password, timezone } = await req.json();

  const check = await checkSignup({
    email,
    ip: clientIp(req.headers),
    userAgent: req.headers.get("user-agent") ?? undefined,
    timezone,
  });
  if (check.recommendation === "block") {
    return Response.json({ error: "We could not create your account. Please use a different email address." }, { status: 400 });
  }

  const { data, error } = await supabase.auth.signUp({ email, password });
  if (error) return Response.json({ error: error.message }, { status: 400 });

  // app_metadata can only be changed with the service role key, so users cannot
  // edit their own risk. Read it in your app to hold back free credits for "review".
  if (data.user) {
    await admin.auth.admin.updateUserById(data.user.id, {
      app_metadata: { signup_risk: check.recommendation, signup_reasons: check.reason_codes },
    });
  }
  return Response.json({ ok: true, user_id: data.user?.id });
}

The result goes into app_metadata on purpose. user_metadata (the data option of signUp) can be changed by the user later, so a bot could simply remove its own review flag. app_metadata can only be changed with the service role key.

4. Use it in your app

recommendationWhat to do
allowCreate the account as usual.
reviewCreate the account, but hold back free credits or the trial until the email is confirmed, or look at it manually.
blockDo not create the account. Show a neutral message.

Read it on the server from the user object, for example user.app_metadata.signup_risk after supabase.auth.getUser(), so your app can give review signups fewer free credits until their email is confirmed. Access tokens issued after the update contain it as well.

Close the back door

Your project's anon key is public, so a script can still call Supabase's signup endpoint directly and skip your route. Two ways to close that gap:

Test it

Sign up with test123@mailinator.com: you should get the neutral error message and no new user in Supabase. A normal address creates the user with signup_risk: "allow" in app_metadata.

The full examples are on GitHub: github.com/signupvet/examples.

Add it to your signup today

SignupVet checks the email, phone and IP of every new signup in one call and spots waves of signups from the same network. Free for 250 signups a month, no card needed.

Get 250 free checks   Read the docs