Docs

Firebase Auth

Check signups in Firebase Auth

Firebase runs a blocking function before it creates a user. Both files the function needs are on this page.

Install

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 first file below in your project 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/signupbouncer-fetch.ts

// SignupBouncer signup check. Docs: https://signupbouncer.com/docs/code/firebase
// 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. With our default message, we word it to
// fit the reason, such as "Did you mean ann@gmail.com?" for a typo domain. Otherwise it returns null.
// signupRefusalWithLink returns null in the same cases, or { message, whyUrl }: the same message and, when you turn
// on the "Why was this refused?" link on the Rules tab, the link to show after it (whyUrl is null otherwise).
//
// confirmationRefusal reports an email confirmation for the second look at challenged signups (POST /v1/confirm).
// It has a 0.8-second limit and fails open the same way, a 4xx answer included: the confirmation goes through.

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. */
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 sent with a block answer (your Rules tab's, or our default for the reason); ours 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;
}

/**
 * The why_url sent with a block answer when the "Why was this refused?" link is on, unchanged; null without one. Only
 * a well-formed http(s) URL with a host counts, so a bad value never becomes a link.
 */
function whyUrlOf(v: Record<string, unknown>): string | null {
  const u = v.why_url;
  if (typeof u !== "string" || u.length > 2048 || !/^https?:\/\/[^\s/\\?#][^\s\\]*$/i.test(u)) return null;
  try {
    const url = new URL(u);
    return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "" ? u : null;
  } catch {
    return null;
  }
}

export interface Refusal {
  /** The message to show: the one from your dashboard's Rules tab, or our default worded for the reason. */
  message: string;
  /** Our "Why was this refused?" page for the email's domain, only when you turn the link on. Otherwise null. */
  whyUrl: string | null;
}

/** 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> {
  return (await signupRefusalWithLink(signup, apiKey))?.message ?? null;
}

/** Like signupRefusal, plus the "Why was this refused?" link when the answer carries one. Never throws. */
export async function signupRefusalWithLink(signup: Signup, apiKey?: string): Promise<Refusal | 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 { message: DEFAULT_MESSAGE, whyUrl: null };
  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
    });
    const answer = await readAnswer(res);
    return isBlock(answer)
      ? { message: messageOf(answer as Record<string, unknown>), whyUrl: whyUrlOf(answer as Record<string, unknown>) }
      : null;
  } catch {
    return null;
  }
}

/** The JSON of a 2xx answer, read in pieces up to MAX_ANSWER_BYTES; null for anything else. Throws on bad JSON. */
async function readAnswer(res: Response): Promise<unknown> {
  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);
  return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
}

// ---- Second look at email confirmation (POST /v1/confirm) ----------------------------------------------------------

export interface Confirmation {
  /** The check_id that /v1/check answered for this signup, if you kept it. */
  checkId?: string;
  /** Supabase: the id of the user who confirmed (data.user.id from verifyOtp). */
  supabaseUserId?: string;
  /** Without a check id: the address that was confirmed, as your auth platform has it. */
  email?: string;
  /** The IP of the request that confirmed, from a header your host sets. */
  ip?: string;
  /** The User-Agent header of the request that confirmed. */
  userAgent?: string;
  /**
   * How the person confirmed: "button" when they pressed a button on your own page, "code" when they typed a code,
   * "link" (the default) when the request is the click on the emailed link itself. Mail scanners open links too, so
   * on "link" we score only what the counters found since the check, never the IP or browser.
   */
  via?: "button" | "code" | "link";
}

/**
 * Only a complete enforce-mode block from a second look refuses a confirmation. A second look that did not happen
 * (second_look false), an error answer or anything partial lets it through.
 */
function isConfirmBlock(v: unknown): boolean {
  if (!object(v) || v.action !== "block" || v.verdict !== "block" || v.mode !== "enforce") return false;
  if (v.second_look !== true || "error" in v) return false;
  return number(v.score) && string(v.check_id) && Array.isArray(v.reasons);
}

/**
 * Reports an email confirmation and returns the message to show when the confirmation should be refused, or null to
 * let it through. Never throws. Any failure, a timeout, a 4xx (an unknown or expired check) or a 5xx lets it through.
 */
export async function confirmationRefusal(confirmation: Confirmation, apiKey?: string): Promise<string | null> {
  try {
    const key = apiKey ?? env("SIGNUPBOUNCER_API_KEY");
    const base = (env("SIGNUPBOUNCER_BASE_URL") || "https://signupbouncer.com").replace(/\/+$/, "");
    const res = await fetch(`${base}/v1/confirm`, {
      method: "POST",
      headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
      body: JSON.stringify({
        check_id: confirmation.checkId,
        supabase_user_id: confirmation.supabaseUserId,
        email: confirmation.email,
        ip: confirmation.ip,
        user_agent: confirmation.userAgent,
        via: confirmation.via ?? "link",
      }),
      redirect: "manual", // a redirect is a failed call: your key goes nowhere else
      // Short: the person is waiting on your confirmation page. Covers reading the answer too.
      signal: AbortSignal.timeout(800),
    });
    const answer = await readAnswer(res);
    return isConfirmBlock(answer) ? messageOf(answer as Record<string, unknown>) : null;
  } catch {
    return null;
  }
}

functions/src/index.ts

// SignupBouncer signup check. Docs: https://signupbouncer.com/docs/code/firebase
// 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 { signupRefusalWithLink } 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 signupRefusalWithLink({
    email,
    ip: event.ipAddress,
    authProvider: provider !== "password" ? provider : undefined,
    emailVerified: trusted && event.data?.emailVerified === true,
  }, SIGNUPBOUNCER_API_KEY.value());
  if (!refusal) return;
  // Firebase passes only the message to your app, so the "Why was this refused?" link goes at its end. We send the
  // link only when you turn it on in your dashboard's Rules tab.
  const why = refusal.whyUrl ? ` Why was this refused? ${refusal.whyUrl}` : "";
  throw new HttpsError("permission-denied", refusal.message + why);
});
  • 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 those 7 seconds.
  • Firebase passes only the error message to your app. When you turn on the "Why was this refused?" link in the Rules tab, the function adds the words and the link to the end of the message.
  • 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.

What the code does when a check fails

In every snippet, the call has a 1.5-second limit, and the code lets the signup through on any failure: a timeout, a network error, a non-2xx answer or an unreadable or incomplete body. It refuses a signup only after a complete answer with "action" and "verdict" both "block", "mode" set to "enforce", and no quota or error flag. The exception is an address that can't be real, which the code refuses without a call: one over 320 characters, or, in the Better Auth plugin, one that isn't an address at all. New apps start in shadow mode, where "action" is always "allow". So the code refuses no other signup until you switch the app to enforce in your dashboard. Each snippet checks only the signups that pass through it. So if your app also creates users somewhere else, such as an OAuth callback or an admin tool, call the check there too.

Shadow mode first

New SignupBouncer apps start in shadow mode. We check and log each signup the function sends, and refuse none of them. When the Checks tab looks right, switch the app to enforce on its Rules tab. Nothing changes in Firebase.

What a refused user sees

In enforce mode, the function throws a permission-denied error with the block message from your Rules tab, and Firebase creates no user. Show the error's message on your signup form.

Firebase passes only the error message to your app. When you turn on the "Why was this refused?" link in the Rules tab, the function adds the words and the link to the end of the message.

Signups the blocking function doesn't check

  • Anonymous sign-ins and custom authentication don't trigger blocking functions.
  • Users your own server creates skip the blocking function.
  • On the Free plan, after 1,000 checks in a month, signups go through unchecked until the month ends.
  • We test the function's check against our API, with a stand-in for the firebase-functions package. We haven't run the function on Firebase itself yet.