Docs

Install signupbouncer in Supabase, or call the REST API from your own code.

Get a key

Sign up with an email address and a password. The Free plan needs no card. The Supabase hook needs no API key. The dashboard's Integration tab shows each app's hook URL and hook secret.

For the REST API, the account owner chooses "Create API key" on that app's Integration tab. Each key belongs to one app, and its checks are logged under that app. We show the key (sb_live_...) once, because we store only a hash of it. Save it in your server's environment as SIGNUPBOUNCER_API_KEY. "Rotate" makes a replacement and keeps the old key working for 24 hours, so you can deploy the new one first.

Install in Supabase

Supabase calls a hook just before it creates a user. signupbouncer answers that hook. No code changes in your app.

  1. Sign up. Your dashboard shows your hook URL and hook secret.
  2. In Supabase, open Authentication, Hooks.
  3. Add a hook. Pick "Before User Created" and the HTTPS type.
  4. Paste the hook URL (it looks like https://signupbouncer.com/hook/supabase/app_...) and the hook secret (it starts with v1,whsec_). Save.
  5. Sign up to your app with a test address. The check appears in your dashboard within seconds.

If you manage Supabase with the CLI, add this to supabase/config.toml instead:

[auth.hook.before_user_created]
enabled = true
uri = "https://signupbouncer.com/hook/supabase/app_..."
secrets = "env(SIGNUPBOUNCER_HOOK_SECRET)"

What your users see

In enforce mode, we refuse a signup marked block with your block message (default: "Please sign up with a permanent email address."). Supabase returns it to your app as the sign-up error. A signup marked challenge is allowed by default. If you choose "Treat as block", it is refused the same way, except a challenge that comes from velocity points, which is allowed and logged. In shadow mode every signup goes through.

The hook's answer carries no typo suggestion, because Supabase accepts only an allow or an error message. To show "Did you mean ann@gmail.com?" on your signup form, call the REST API from your own code and read suggestion.

If we are down

Supabase refuses the signup when a hook fails. So the hook URL goes through a small Cloudflare Worker. When our server hits an error, is overloaded or takes longer than 1.2 seconds, it answers allow itself. When our server is down or does not answer within 1.5 seconds, the Worker answers allow. Either way the signup goes through unchecked, including signups from known burner domains.

If Cloudflare fails, or Supabase cannot reach the Worker, Supabase refuses the signup as it does for any hook that fails. Supabase's own outages are outside our control too.

Which signups are checked

Email and password, magic link, phone, and OAuth signups (Google, GitHub and others) all pass through the hook. For OAuth and SSO signups that Supabase marks as email-verified, we check the IP and signup bursts only, because the provider has already verified the email. Other OAuth signups get the email checked too. Existing users signing in are not checked.

REST API

Use it from any backend. Call it in your signup handler before you create the user. The API is described as OpenAPI 3.1 at /openapi.json.

POST https://signupbouncer.com/v1/check
Authorization: Bearer sb_live_...
Content-Type: application/json

{ "email": "a.b+x@gmail.com", "ip": "203.0.113.7", "user_agent": "...",
  "auth_provider": "google", "email_verified": true }

All fields are optional, but send at least an email or an IP. The key sets the app, so leave out app. If you send it, it must be the key's own app id, or the call is refused with 403 app_mismatch. Older keys, labelled "all apps (older key)" on the dashboard, take the app from app, and use your first app without it. For OAuth and SSO sign-ups, send the email with auth_provider set to the provider id ("google", "github", "google.com", "microsoft-entra-id", "sso:<id>"...), and email_verified set from your auth platform's own flag (an OIDC email_verified claim, Firebase's emailVerified), never from what the user typed. When we know the id as an OAuth or SSO provider and email_verified is true, we score only the IP and signup bursts, and the domain still counts in your stats. Otherwise we check the email like an email signup: when email_verified is missing or false, or the id is one we don't know, such as an email or password provider ("nodemailer", "credentials").

For IP-only checks, send a stable attempt_id (1 to 128 letters, digits, . _ : or -) and reuse it on retries. Without it, the exact email identifies the signup, or each IP-only call counts separately. This deduplicates signup counters, not billing or API responses. Response:

200 {
  "verdict": "block",            // allow | challenge | block
  "action": "allow",             // what to do: same as "verdict" in enforce mode, always "allow" in shadow mode
  "mode": "shadow",              // shadow | enforce
  "score": 100,
  "check_id": "chk_...",
  "reasons": [ { "code": "disposable_domain", "detail": "...", "points": 100, "tier": "free" } ],
  "normalized": { "provider": "gmail", "repeat_signups_7d": 0 },
  "network": { "asn": 14061, "asn_org": "...", "kinds": ["datacenter"], "label": "...", "prefix": "203.0.113.0/24" },
  "shadow": [ { "code": "datacenter_ip", "tier": "starter", "detail": "..." } ],
  "latency_ms": 2.1,
  "quota_exceeded": false
}
  • shadow lists what a higher plan would also have flagged. It does not change the score.
  • suggestion appears only when the email's domain looks like a typo of a big mail provider, such as ann@gmial.com. It holds the address with the domain corrected: "suggestion": "ann@gmail.com". Show it to the person signing up as a question ("Did you mean ann@gmail.com?") and let them fix the address themselves. Never change it for them. It does not change the score or billing.
  • block_message appears only when action is "block" or "challenge". It holds the message you set on your dashboard's Rules tab ("Message a blocked user sees"). Show it to the person when you refuse the signup.
  • On the Free plan, over the monthly checks the answer is "action": "allow" with "quota_exceeded": true. Nothing is checked and nothing is billed. On monthly paid plans, checking continues past the allowance ("quota_exceeded": false), and each extra check is billed at $2 per 1,000, up to the account's monthly cap on extra checks. At that cap, and on yearly plans past the allowance, the answer is again "action": "allow" with "quota_exceeded": true, unchecked and never billed. We never block a signup because of our own limits.
  • network is null without a usable IP. If something fails on our side, the answer is still a complete 200 with "action": "allow" and "error": "internal_error".
  • Give the call a short timeout (the hook's Worker waits 1.5 seconds) and let the signup through when the call fails or times out, so a problem on our side never blocks your users.

Errors

Each error is JSON with an error code and a message that says what to do.

  • 401 invalid_api_key: the Authorization header is missing, or the key is wrong, revoked, or was rotated more than 24 hours ago.
  • 400 invalid_body: the body is not a JSON object, has neither an email nor an IP, or has a bad auth_provider or attempt_id.
  • 403 app_mismatch: the key belongs to one app, and app holds another app's id.
  • 404 unknown_app: with an older all-apps key, the app id is not on this key's account.
  • 413 body_too_large: the body is over 64 KB.
  • 404 not_found: any path under /v1/ other than POST /v1/check.

Code for your stack

In every snippet the call has a 1.5-second limit, and any failure lets the signup through: a timeout, a network error, a non-2xx answer or an unreadable or incomplete body. You refuse a signup only after a complete answer with "action" and "verdict" both "block", "mode" set to "enforce", and no quota or error flag. New apps start in shadow mode, where "action" is always "allow", so no signup is refused until you switch the app to enforce in your dashboard. Each snippet checks the signups that pass through the code it sits in. If your app also creates users somewhere else, such as an OAuth callback or an admin tool, call the check there too.

Supabase needs no code. See Install in Supabase. AI coding assistants can fetch the same code through the MCP server's integration_guide tool.

Any server with fetch (TypeScript)

One function for Node 18 and later, Deno, Bun, Cloudflare Workers and any runtime with fetch. The other TypeScript examples import it.

  1. Copy the file into your project as lib/signupbouncer-fetch.ts.
  2. Create an API key for the app on its Integration tab in your dashboard, then set SIGNUPBOUNCER_API_KEY in your server's environment to that key (sb_live_...).
  3. In your signup handler, call signupRefusal before you create the user. When it returns a message, refuse the signup and show that message. It is the one you set on your dashboard's Rules tab.

lib/signupbouncer-fetch.ts

// lib/signupbouncer-fetch.ts: checks one signup with signupbouncer. Works in Node 18+, Deno, Bun, Next.js, Cloudflare
// Workers and any runtime with fetch. It reads SIGNUPBOUNCER_API_KEY from process.env, or takes the key as its second
// argument (in a Cloudflare Worker, pass the secret from env, because Workers have no process.env by default).
// Leave SIGNUPBOUNCER_BASE_URL unset. It points the helper at another copy of the API, such as one on your machine.
//
// The call has a 1.5-second limit, and any failure lets the signup through: a timeout, a network error, a non-2xx
// answer or an unreadable body. Only a complete, consistent enforce-mode block answer refuses the signup: then it
// returns the message to show, the one you set on your dashboard's Rules tab. Otherwise it returns null.

export interface Signup {
  email?: string;
  /** The client's IP, from a header your host sets. Leave it out if you are unsure. */
  ip?: string;
  /** Only for OAuth/SSO signups: the provider id ("google", "github", "google.com", ...). Send emailVerified with it. */
  authProvider?: string;
  /**
   * Your auth platform's own flag that the provider verified this address (an OIDC email_verified claim, Firebase's
   * emailVerified). Never what the user typed. Without true, the email is checked too.
   */
  emailVerified?: boolean;
}

const MAX_ANSWER_BYTES = 65536; // a real answer is far smaller
const DEFAULT_MESSAGE = "Please sign up with a permanent email address.";

const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
const number = (v: unknown) => typeof v === "number" && Number.isFinite(v);
const string = (v: unknown) => typeof v === "string";
const nullableString = (v: unknown) => v === null || string(v);

/** Only a complete, consistent answer may refuse a signup. Partial/error answers fail open (astra-mcp#2). */
function isBlock(v: unknown): boolean {
  if (!object(v) || v.action !== "block" || v.verdict !== "block" || v.mode !== "enforce") return false;
  if (v.quota_exceeded !== false || "error" in v) return false;
  if (!number(v.score) || !string(v.check_id) || !number(v.latency_ms)) return false;
  if (
    !Array.isArray(v.reasons) ||
    !v.reasons.every((r) => object(r) && string(r.code) && string(r.detail) && number(r.points) && string(r.tier))
  ) return false;
  if (
    !Array.isArray(v.shadow) ||
    !v.shadow.every((r) => object(r) && string(r.code) && string(r.detail) && string(r.tier))
  ) return false;
  const n = v.normalized;
  if (!object(n) || !nullableString(n.provider) || !number(n.repeat_signups_7d)) return false;
  const net = v.network;
  return net === null ||
    (object(net) && (net.asn === null || number(net.asn)) && nullableString(net.asn_org) &&
      Array.isArray(net.kinds) && net.kinds.every(string) && nullableString(net.label) && string(net.prefix));
}

/** A variable from process.env, or undefined where there is none (a Worker) or reading it is not allowed (Deno). */
function env(name: string): string | undefined {
  try {
    return typeof process === "undefined" ? undefined : process.env[name];
  } catch {
    return undefined;
  }
}

/** The message from your dashboard's Rules tab, sent with a block answer; the default if it is missing. */
function messageOf(v: Record<string, unknown>): string {
  const m = v.block_message;
  return typeof m === "string" && m.trim() !== "" && m.length <= 500 ? m : DEFAULT_MESSAGE;
}

/** Returns the message to show when the signup should be refused, or null to let it through. Never throws. */
export async function signupRefusal(signup: Signup, apiKey?: string): Promise<string | null> {
  // No real address is longer than 320 characters. Refusing them here keeps the request small enough to be checked.
  if ((signup.email?.length ?? 0) > 320) return DEFAULT_MESSAGE;
  try {
    const key = apiKey ?? env("SIGNUPBOUNCER_API_KEY");
    const base = (env("SIGNUPBOUNCER_BASE_URL") || "https://signupbouncer.com").replace(/\/+$/, "");
    const res = await fetch(`${base}/v1/check`, {
      method: "POST",
      headers: {
        authorization: `Bearer ${key}`,
        "content-type": "application/json",
      },
      body: JSON.stringify({
        email: signup.email,
        ip: signup.ip,
        auth_provider: signup.authProvider,
        email_verified: signup.emailVerified === true ? true : undefined,
      }),
      redirect: "manual", // a redirect is a failed check: your key and the address go nowhere else
      signal: AbortSignal.timeout(1500), // covers reading the answer too
    });
    if (!res.ok || !res.body) {
      await res.body?.cancel();
      return null;
    }
    // Read the answer in pieces and stop as soon as it is larger than any real answer.
    const reader = res.body.getReader();
    const parts: Uint8Array[] = [];
    let size = 0;
    for (let r = await reader.read(); !r.done; r = await reader.read()) {
      size += r.value.byteLength;
      if (size > MAX_ANSWER_BYTES) {
        await reader.cancel();
        return null;
      }
      parts.push(r.value);
    }
    const body = new Uint8Array(size);
    parts.reduce((at, p) => (body.set(p, at), at + p.byteLength), 0);
    const answer = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
    return isBlock(answer) ? messageOf(answer) : null;
  } catch {
    return null;
  }
}
  • Send the IP only from a header your host overwrites with the client's address. A header your host passes through unchanged can be set by the client, so leave ip out if you are unsure. The email is still checked.

Next.js (App Router)

A route handler for a signup API route, and a server action with its form (useActionState shows the error). Both use lib/signupbouncer-fetch.ts.

  1. Copy lib/signupbouncer-fetch.ts from the first example.
  2. Create an API key for the app on its Integration tab in your dashboard, then set SIGNUPBOUNCER_API_KEY in your server's environment to that key (sb_live_...).
  3. Add the route handler, or the server action and the form, and create the user where the comment says.

app/api/signup/route.ts

// app/api/signup/route.ts: a Next.js App Router route handler that checks the signup before creating the user.
import { signupRefusal } from "@/lib/signupbouncer-fetch";

export async function POST(req: Request) {
  const { email } = await req.json();
  // On Vercel, x-real-ip holds the client IP. On other hosts, use the header your host sets, or leave ip out.
  const ip = req.headers.get("x-real-ip") ?? undefined;
  const refusal = await signupRefusal({ email, ip });
  if (refusal) return Response.json({ error: refusal }, { status: 400 });
  // Create the user with your auth library here.
  return Response.json({ ok: true });
}

app/signup/actions.ts

// app/signup/actions.ts: a Next.js server action for the signup form in form.tsx (useActionState).
"use server";

import { headers } from "next/headers";
import { signupRefusal } from "@/lib/signupbouncer-fetch";

export type SignUpState = { error?: string };

export async function signUp(_previous: SignUpState, formData: FormData): Promise<SignUpState> {
  const email = String(formData.get("email") ?? "");
  // On Vercel, x-real-ip holds the client IP. On other hosts, use the header your host sets, or leave ip out.
  const ip = (await headers()).get("x-real-ip") ?? undefined;
  const refusal = await signupRefusal({ email, ip });
  if (refusal) return { error: refusal };
  // Create the user with your auth library here, then redirect() to your app.
  return {};
}

app/signup/form.tsx

// app/signup/form.tsx: the signup form for the server action in actions.ts. It shows the action's error message.
"use client";

import { useActionState } from "react";
import { signUp, type SignUpState } from "./actions";

const initial: SignUpState = {};

export function SignUpForm() {
  const [state, formAction, pending] = useActionState(signUp, initial);
  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit" disabled={pending}>Sign up</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}
  • On Vercel, x-real-ip holds the client IP. Send the IP only from a header your host overwrites with the client's address. A header your host passes through unchanged can be set by the client, so leave ip out if you are unsure. The email is still checked.

Express

A signup route for Express. It uses signupbouncer-fetch.ts from the first example, saved next to it.

  1. Save the fetch example as signupbouncer-fetch.ts next to your server file.
  2. Create an API key for the app on its Integration tab in your dashboard, then set SIGNUPBOUNCER_API_KEY in your server's environment to that key (sb_live_...).
  3. Set "trust proxy" to match your hosting, so req.ip is the client IP.

server.ts

// server.ts: an Express signup route that checks the signup before creating the user.
import express from "express";
import { signupRefusal } from "./signupbouncer-fetch";

const app = express();
app.use(express.json());
// req.ip is the client IP only when "trust proxy" matches your hosting. Leave ip out if you are unsure.
app.set("trust proxy", 1);

app.post("/signup", async (req, res) => {
  const { email } = req.body;
  const refusal = await signupRefusal({ email, ip: req.ip });
  if (refusal) {
    res.status(400).json({ error: refusal });
    return;
  }
  // Create the user here.
  res.json({ ok: true });
});

export default app;
  • Send the IP only from a header your host overwrites with the client's address. A header your host passes through unchanged can be set by the client, so leave ip out if you are unsure. The email is still checked.

Hono

A signup route for Hono. It uses signupbouncer-fetch.ts from the first example, saved next to it.

  1. Save the fetch example as signupbouncer-fetch.ts next to your server file.
  2. On Cloudflare Workers, store your key as a secret: wrangler secret put SIGNUPBOUNCER_API_KEY. The route passes it from c.env, because Workers have no process.env. On Node, Deno or Bun, set SIGNUPBOUNCER_API_KEY in the environment.
  3. Read the client IP from the header your host sets. Behind Cloudflare that is cf-connecting-ip.

server.ts

// server.ts: a Hono signup route that checks the signup before creating the user. On Cloudflare Workers, store the
// key with `wrangler secret put SIGNUPBOUNCER_API_KEY`. On Node, Deno and Bun, set it in the environment.
import { Hono } from "hono";
import { signupRefusal } from "./signupbouncer-fetch";

const app = new Hono<{ Bindings: { SIGNUPBOUNCER_API_KEY?: string } }>();

app.post("/signup", async (c) => {
  const { email } = await c.req.json();
  // Behind Cloudflare, cf-connecting-ip holds the client IP. Elsewhere, use the header your host sets, or leave ip out.
  const ip = c.req.header("cf-connecting-ip");
  // Workers have no process.env, so the key comes from the Worker's bindings. Elsewhere the helper reads process.env.
  const refusal = await signupRefusal({ email, ip }, c.env?.SIGNUPBOUNCER_API_KEY);
  if (refusal) return c.json({ error: refusal }, 400);
  // Create the user here.
  return c.json({ ok: true });
});

export default app;
  • Send the IP only from a header your host overwrites with the client's address. A header your host passes through unchanged can be set by the client, so leave ip out if you are unsure. The email is still checked.

Python (requests)

One function for Django, Flask, FastAPI or any Python backend. It needs the requests package.

  1. Save the file as signupbouncer.py in your project.
  2. Create an API key for the app on its Integration tab in your dashboard, then set SIGNUPBOUNCER_API_KEY in your server's environment to that key (sb_live_...).
  3. In your signup view, call signup_refusal(email, ip) before you create the user. When it returns a message, refuse the signup and show that message.

signupbouncer.py

# signupbouncer.py: checks one signup with signupbouncer. Needs the requests package and SIGNUPBOUNCER_API_KEY in
# the environment. Leave SIGNUPBOUNCER_BASE_URL unset. It points the helper at another copy of the API, such as one on
# your machine.
#
# The call has a 1.5-second limit in total, and any failure lets the signup through: a timeout, a network error, a
# non-2xx answer or an unreadable body. Only a complete, consistent enforce-mode block answer refuses the signup: then
# it returns the message to show, the one you set on your dashboard's Rules tab. Otherwise it returns None.
import json
import math
import os
from concurrent.futures import ThreadPoolExecutor
from threading import BoundedSemaphore
from time import monotonic

import requests

MAX_ANSWER_BYTES = 65536  # a real answer is far smaller
DEFAULT_MESSAGE = "Please sign up with a permanent email address."
# requests' own timeout limits each network read, not the whole call, so the call runs in a worker thread and your
# signup view stops waiting after 1.5 seconds whatever the API does. At most eight calls, with no waiting backlog.
_pool = ThreadPoolExecutor(max_workers=8)
_slots = BoundedSemaphore(8)


def _is_block(v):
    # A partial or contradictory answer must never refuse a real signup (astra-mcp#2).
    number = lambda x: type(x) in (int, float) and math.isfinite(x)
    string = lambda x: isinstance(x, str)
    nullable = lambda x: x is None or string(x)
    if not isinstance(v, dict) or any(v.get(k) != val for k, val in
            (("action", "block"), ("verdict", "block"), ("mode", "enforce"))):
        return False
    if v.get("quota_exceeded") is not False or "error" in v:
        return False
    if not number(v.get("score")) or not string(v.get("check_id")) or not number(v.get("latency_ms")):
        return False
    for field, points in (("reasons", True), ("shadow", False)):
        rows = v.get(field)
        if not isinstance(rows, list) or not all(isinstance(r, dict) and
                all(string(r.get(k)) for k in ("code", "detail", "tier")) and
                (not points or number(r.get("points"))) for r in rows):
            return False
    n = v.get("normalized")
    if not isinstance(n, dict) or "provider" not in n or not nullable(n["provider"]) or not number(n.get("repeat_signups_7d")):
        return False
    if "network" not in v:
        return False
    net = v["network"]
    return net is None or (isinstance(net, dict) and
        all(k in net for k in ("asn", "asn_org", "kinds", "label", "prefix")) and
        (net["asn"] is None or number(net["asn"])) and nullable(net["asn_org"]) and
        isinstance(net["kinds"], list) and all(string(k) for k in net["kinds"]) and
        nullable(net["label"]) and string(net["prefix"]))


def _message(v):
    # The message from your dashboard's Rules tab, sent with a block answer; the default if it is missing.
    m = v.get("block_message")
    return m if isinstance(m, str) and m.strip() and len(m) <= 500 else DEFAULT_MESSAGE


def signup_refusal(email=None, ip=None, auth_provider=None, email_verified=None):
    # Returns the message to show when the signup should be refused, or None to let it through.
    # No real address is longer than 320 characters. Refusing them here keeps the request small enough to be checked.
    if email and len(email) > 320:
        return DEFAULT_MESSAGE
    # auth_provider: the provider id, for OAuth/SSO signups only. email_verified: your auth platform's own flag that
    # the provider verified this address (an OIDC email_verified claim), never what the user typed. Only True counts.
    body = {k: v for k, v in {"email": email, "ip": ip, "auth_provider": auth_provider}.items() if v}
    if email_verified is True:
        body["email_verified"] = True
    # No unbounded queue of checks after their signup already continued (astra-mcp#6).
    deadline = monotonic() + 1.5
    if not _slots.acquire(blocking=False):
        return None
    try:
        future = _pool.submit(_check, body, deadline)
    except Exception:
        _slots.release()
        return None
    future.add_done_callback(lambda _: _slots.release())
    try:
        return future.result(timeout=max(0, deadline - monotonic()))
    except Exception:
        future.cancel()
        return None


def _check(body, deadline):
    remaining = deadline - monotonic()
    if remaining <= 0:
        return None
    with requests.post(
        (os.environ.get("SIGNUPBOUNCER_BASE_URL") or "https://signupbouncer.com").rstrip("/") + "/v1/check",
        headers={"Authorization": f"Bearer {os.environ.get('SIGNUPBOUNCER_API_KEY', '')}"},
        json=body,
        timeout=remaining,
        allow_redirects=False,  # a redirect is a failed check: your key and the address go nowhere else
        stream=True,
    ) as r:  # closes the connection on every path
        if r.status_code != 200:
            return None
        data = bytearray()
        # One decoded byte at a time also checks the deadline on a continuously trickling response. A blocked
        # network read has its own timeout; its slot stays occupied until it exits, even after the caller returned.
        for chunk in r.iter_content(chunk_size=1):
            if monotonic() >= deadline:
                return None
            data.extend(chunk)
            if len(data) > MAX_ANSWER_BYTES:
                return None
        if monotonic() >= deadline:
            return None
        answer = json.loads(data)
        return _message(answer) if _is_block(answer) else None


if __name__ == "__main__":
    import sys

    refusal = signup_refusal(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)
    print("allow" if refusal is None else "refuse")
  • You wait at most 1.5 seconds for the check. At most eight Python checks run at once, with no waiting backlog. When all slots are occupied, signups continue unchecked. Expired jobs are cancelled before they start, and streaming stops at the deadline. An in-progress network operation may finish later and holds its slot until then.
  • Send the IP only from a header your host overwrites with the client's address. A header your host passes through unchanged can be set by the client, so leave ip out if you are unsure. The email is still checked.

Firebase Auth

A 2nd-gen blocking function that Firebase runs before it creates a user who signs up with email and password, an email link, a phone number or an OAuth provider. Blocking functions need Firebase Authentication with Identity Platform.

  1. Save the fetch example as functions/src/signupbouncer-fetch.ts.
  2. Store your key with firebase functions:secrets:set SIGNUPBOUNCER_API_KEY. The function reads it with defineSecret and passes it to signupRefusal.
  3. Add the function to functions/src/index.ts and deploy with firebase deploy --only functions.

functions/src/index.ts

// functions/src/index.ts: a Firebase Auth blocking function (2nd gen, needs Identity Platform) that checks new users
// before Firebase creates them. Anonymous sign-in and custom authentication do not trigger blocking functions. Check
// users created those ways in your own code. Store the key with: firebase functions:secrets:set SIGNUPBOUNCER_API_KEY
import { beforeUserCreated, HttpsError } from "firebase-functions/v2/identity";
import { defineSecret } from "firebase-functions/params";
import { signupRefusal } from "./signupbouncer-fetch";

const SIGNUPBOUNCER_API_KEY = defineSecret("SIGNUPBOUNCER_API_KEY");

export const signupbouncer = beforeUserCreated({ secrets: [SIGNUPBOUNCER_API_KEY] }, async (event) => {
  // An OAuth provider ("google.com", ...) and Firebase's own emailVerified: with both, signupbouncer scores only the
  // IP and bursts. Firebase does not trust Facebook, Twitter or GitHub to verify an address, nor Google, Yahoo or
  // Microsoft for domains they do not issue, so those are sent unverified and the email is checked too.
  const provider = event.additionalUserInfo?.providerId;
  const email = event.data?.email ?? undefined;
  const domain = email?.split("@").pop()?.toLowerCase() ?? "";
  const own: Record<string, string[]> = {
    "google.com": ["gmail.com", "googlemail.com"],
    "yahoo.com": ["yahoo.com"],
    "microsoft.com": ["outlook.com", "hotmail.com"],
  };
  const trusted = !!provider && !["password", "facebook.com", "twitter.com", "github.com"].includes(provider) &&
    (!own[provider] || own[provider].includes(domain));
  const refusal = await signupRefusal({
    email,
    ip: event.ipAddress,
    authProvider: provider !== "password" ? provider : undefined,
    emailVerified: trusted && event.data?.emailVerified === true,
  }, SIGNUPBOUNCER_API_KEY.value());
  if (refusal) throw new HttpsError("permission-denied", refusal);
});
  • Anonymous sign-in and custom authentication do not trigger blocking functions. If your app creates users those ways, or from your own server, check those signups in your own code before you grant free credits.
  • Firebase refuses the signup when a blocking function throws or runs longer than 7 seconds. The 1.5-second limit keeps the check well inside that.
  • When Google, Apple or another OAuth provider has verified the email, the function sends the provider id, and signupbouncer scores the IP and signup bursts only. Otherwise the email gets the full check.

Better Auth

A before hook that checks email and password signups (the /sign-up/email endpoint) before Better Auth creates the user.

  1. Save the fetch example as lib/signupbouncer-fetch.ts.
  2. Create an API key for the app on its Integration tab in your dashboard, then set SIGNUPBOUNCER_API_KEY in your server's environment to that key (sb_live_...).
  3. Add the hook to your config: betterAuth({ ..., hooks: { before: signupbouncerHook } }).

lib/signupbouncer-hook.ts

// lib/signupbouncer-hook.ts: checks Better Auth email and password signups before the user is created.
// Add it to your config: betterAuth({ ..., hooks: { before: signupbouncerHook } })
import { APIError, createAuthMiddleware } from "better-auth/api";
import { signupRefusal } from "./signupbouncer-fetch";

export const signupbouncerHook = createAuthMiddleware(async (ctx) => {
  if (ctx.path !== "/sign-up/email") return;
  // Use the header your host sets with the client IP (Vercel: x-real-ip, Cloudflare: cf-connecting-ip), or leave ip out.
  const ip = ctx.headers?.get("x-real-ip") ?? undefined;
  const refusal = await signupRefusal({ email: ctx.body?.email, ip });
  if (refusal) throw new APIError("BAD_REQUEST", { message: refusal });
});
  • This hook checks email and password signups only. OAuth and magic-link signups, anonymous users, users created with the admin plugin and users your server creates go through other paths. Check those in your own code before you grant free credits.
  • A refused signup gets a 400 answer with the message, as Better Auth returns any APIError("BAD_REQUEST").

Testing

To try addresses without a key, use the free disposable email checker. It runs the same email and IP checks.

With a key, check someone@mailinator.com to see it marked block. New apps start in shadow mode, so action stays allow until you switch the app to enforce in the dashboard. Test checks count toward your monthly allowance. Retries of one exact address on one app count as one signup. Different aliases or apps can add repeat_inbox points for 7 days. Gmail dots and +tags share an inbox. Use a new address for independent tests.

Reason codes

This list is fixed, so you can write rules against it. The plan column shows the lowest plan on which it counts.

CodePlanMeaning
invalid_syntaxFreeNot a valid email address. Blocks.
disposable_domainFreeThe domain is on our burner list. Blocks.
burner_mxFreeThe domain's mail server is one our harvester classifies as a burner mail host. It serves 3+ listed burner domains and no allowlisted real provider, and is either reviewed by hand or named after a listed burner domain. Catches new burner domains. Blocks.
no_mxFreeThe domain cannot receive mail: a null MX record, or no MX record and no A/AAAA record to fall back to. Blocks. A DNS failure or timeout does not count.
free_subdomain_mailFreeMail on a free subdomain service. 45 points, which is a challenge at the default cutoffs and not a block.
repeat_inboxFreeThe same normalised inbox was checked before on your account, in any of your apps, in the last 7 days. Every earlier check counts, including test calls and signups that failed later. A retried hook call for the same signup within 10 minutes counts once. 25 points for one earlier check, 40 for two, 55 for three and 70 for four, which is a block at the default cutoffs. Five or more add 75.
relay_serviceFreeA forwarding service such as SimpleLogin or Hide My Email. 10 points, a weak signal that never blocks alone. From the Starter plan, an alias on a commercial VPN IP with no other signal adds no points, because the two count as one privacy signal. With any other signal, it adds its 10.
role_accountFreeadmin@, info@, support@ and similar. Weak signal.
plus_aliasFreeA tag or punctuation the provider ignores was removed, such as Gmail dots or a Gmail +tag. Information only, 0 points.
datacenter_ipStarterThe IP belongs to a hosting or cloud network. A challenge at the default cutoffs. If you lower the block score to 40 or below, it blocks on its own.
vpn_ipStarterThe IP is a known commercial VPN exit. Never blocks alone.
tor_exitStarterThe IP is a Tor exit node. A challenge at the default cutoffs, never a block alone.
privacy_relayStarteriCloud Private Relay or similar. Information only, because many real people use it.
residential_proxyProComing. The rule is built and switches on once residential-proxy exit data is collected. Until then this code never appears.
burst_subnetProEarlier signups from the same /24 (/48 for IPv6) in the last hour: by default 5 on this app or 10 across your apps. A retried signup counts once. 25 points at the threshold, 40 at twice it, 60 at five times. Like every burst_ code, it can raise a signup to challenge but never to block, because a school or office network can burst too.
burst_asnProEarlier signups from the same hosting or VPN network (ASN) in the last hour, across all its subnets: by default 10 on this app or 20 across your apps. Home and mobile networks are not counted. Points as burst_subnet. Never a block, even together with datacenter_ip, since an office proxy in the cloud is both.
burst_inboxProDifferent spellings of the same inbox (Gmail dots and +tags) on this app in the last 24 hours: by default 2 earlier spellings. Points as burst_subnet, but it shares a group with repeat_inbox (which counts repeats across all your apps), and only the stronger of the two counts. Never a block.
burst_patternProAddresses at the same domain built from the same name and a number of 3 or more digits (jsmith4821@, jsmith9930@) in the last hour: by default 4 earlier on this app or 8 across your apps. Birth years and 1-2 digit numbers do not count, in any script. Points as burst_subnet. Never a block, since popular names with numbers match by chance.
override_allowFreeMatched one of your allow overrides. Always allows.
override_blockFreeMatched one of your block overrides. Always blocks.

Scoring: each reason adds points. Network reasons (datacenter, VPN, Tor, privacy relay, residential proxy) describe one IP, so only the strongest of them counts. From the Starter plan, an email alias on a commercial VPN with no other signal counts once, as the VPN's points. With any other signal, the alias adds its 10. By default, a score of 40 marks the signup challenge and 70 marks it block. You can change both cutoffs per app, which changes what the other reasons lead to. A score made only of weak signals (VPN, Tor, privacy relay, role account, relay service) can reach challenge but never block. Some reasons set the result on their own: an override, an invalid address, a known burner domain, a burner mail server, or a domain that cannot receive mail.

Velocity (the burst_ codes): each counts earlier signups in a fixed window, on the app and across all your apps, and you can change the thresholds per app in the dashboard's Velocity section (0 turns one off, otherwise the lowest is 2). Velocity points count in the score and can raise a signup to challenge but never to block. A block needs the other reasons to reach your block score on their own. Classmates on one network, colleagues behind an office proxy and people with popular names all produce velocity patterns.

Shadow and enforce

New apps start in shadow mode. Signups are checked and logged, and every signup is allowed. When the log looks right, switch to enforce in the dashboard.

The Supabase hook cannot show a captcha to a signup marked challenge. You choose: allow and log it (the default, which relies on Supabase's own email confirmation), or treat it as a block. A challenge that comes from velocity points is always allowed and logged.

What it checks

signupbouncer scores a signup's email address and IP address, and on the Pro plan, bursts of signups. Captchas, phone number checks, device fingerprinting and mailbox (SMTP) probing are outside it. Pair it with another tool for those. Residential-proxy detection is coming on the Pro plan. A signup marked challenge is suspicious but not a block, and you choose what your app does with it.

MCP server

AI assistants and coding agents can use the free checker, and fetch the code for your stack, through MCP (Model Context Protocol). Add a remote server with this URL, no key needed:

https://signupbouncer.com/mcp

It speaks the Streamable HTTP transport and answers each request with plain JSON, with no sessions and no event stream. It has four read-only tools:

  • integration_guide: the steps and code from Code for your stack for one stack (supabase, fetch, nextjs, express, hono, python, firebase or better-auth).
  • check_email: checks one address, and optionally an IP, like the disposable email checker. It returns allow, challenge or block, the score, every reason with its code, the normalised inbox and the IP's network, plus suggestion when the domain looks like a typo of a big mail provider.
  • explain_reason: what a reason code means and the lowest plan on which it counts. Without a code, lists them all.
  • lookup_burner_domain: whether a domain is on our burner list (or is a relay service, a free subdomain mail service or a known real provider), without a DNS lookup.

The same limits and privacy rules as the free checker apply. check_email and lookup_burner_domain share its allowance per IP address (20 a minute, 200 a day), and nothing a tool receives is stored or logged. check_email counts every reason, including reasons that count only on paid plans, so its score can differ from your app's. To check real signups in your app, use the Supabase hook or the REST API with a free key.

Assistants hosted by their provider, such as claude.ai, call from the provider's servers and share its IP addresses. To get your account's own free allowance (20 a minute, 200 a day per account), send your API key as Authorization: Bearer sb_live_... in clients that let you set headers. Calls with a key do not count as checks against your plan. A key that is sent but not valid gets a 401.

Add it to your client

Claude Code:

claude mcp add --transport http signupbouncer https://signupbouncer.com/mcp

# with your key:
claude mcp add --transport http signupbouncer https://signupbouncer.com/mcp \
  --header "Authorization: Bearer sb_live_..."

Cursor, in .cursor/mcp.json (the headers part is optional):

{
  "mcpServers": {
    "signupbouncer": {
      "url": "https://signupbouncer.com/mcp",
      "headers": { "Authorization": "Bearer ${env:SIGNUPBOUNCER_API_KEY}" }
    }
  }
}

VS Code, in .vscode/mcp.json:

{
  "servers": {
    "signupbouncer": { "type": "http", "url": "https://signupbouncer.com/mcp" }
  }
}

Claude Desktop and claude.ai: add a custom connector with the URL above. Other clients: add a remote (HTTP) MCP server with the same URL.

Questions

Does it block Gmail dot and +tag aliases?

It folds Gmail dots and +tags into one inbox. The alias alone adds no points. Signing up again with the same inbox does. One earlier check in the last 7 days adds 25 points, rising to 70 points for four, which is a block at the default cutoffs. On the Pro plan, several spellings of one inbox on one app within 24 hours also count.

What happens to signups if signupbouncer is down?

With the REST API, you give the call a 1.5-second limit and let the signup through when it fails, as every example on this page does. With the Supabase hook, a Cloudflare Worker sits in front of our server and answers allow when our server hits an error, is down or takes longer than 1.5 seconds. If Cloudflare fails, or Supabase cannot reach the Worker, Supabase refuses the signup, as it does for any hook that fails.

Does it check Google and other OAuth signups?

Yes. The Supabase hook sees OAuth signups. When Supabase marks the address as verified by a known provider, we score the IP and signup bursts only. With the REST API, send the provider id as "auth_provider" and the flag from your auth platform as "email_verified" for the same treatment. Without "email_verified": true, or with a provider id we do not know as an OAuth or SSO provider, the email is checked like an email signup.

What does it store about my users?

Raw email addresses and IP addresses exist only in memory while a check runs. Per check we store the email domain, the part before the @ as an HMAC keyed per account, the IP's /24 (/48 for IPv6), the network number, the score, the result (allow, challenge or block) and the reasons. Check-log rows are deleted after 30 days, except rows from our launch test, which are kept until its result is recorded.

How is it different from a list of disposable domains?

A list catches the domains on it. signupbouncer also looks up each domain's mail servers, which catches new domains on known burner mail hosts and domains that cannot receive mail. It counts repeat signups from one inbox across Gmail aliases, and on paid plans it scores datacenter, VPN and Tor IPs (from the Starter plan) and bursts of signups (from the Pro plan).

Data handling

  • Raw emails and IPs exist only in memory while a check runs. We never store them.
  • Stored per check: email domain, the part before the @ as an HMAC keyed per account, the IP's /24 (/48 for IPv6), the ASN, the score, the result (allow, challenge or block), the reasons and the latency.
  • A job every 6 hours deletes check-log rows older than 30 days. Rows from our 42-day launch test are kept until its result is recorded.
  • Repeat-signup counting keeps the normalised inbox as an HMAC keyed per account, one entry per check, deleted 7 days after its check (or 7 days after the inbox's last check if the cleanup job stops).
  • Short-lived counters and caches: velocity counters per app and per account (per /24, per hosting or VPN network and per keyed hash of the address pattern for 1 hour, per inbox hash for 24 hours, each entry deleted when its window is up, and holding only keyed hashes and check ids), hook retry answers for 10 minutes, mail-server lookups per domain for 1 hour (10 minutes when the domain cannot receive mail).
  • Email and IP overrides you add are stored as HMACs too.
  • The server is in the US (Virginia), behind Cloudflare.
  • A data processing agreement and an EU-only setting are planned.