1. Home
  2. Signup protection
  3. Firebase

Signup protection recipe

Block fake and disposable signups in Firebase Auth

A beforeUserCreated blocking function runs before Firebase Auth saves a new user. Check the address with SimpleVerifier inside it, throw an HttpsError for disposable, invalid and misspelled addresses, and return custom claims for role and catch-all ones so your rules can hold free credits until the email is confirmed.

Where it runs
Runs before the user is saved. A thrown HttpsError stops the account being created.
Timeout
Blocking functions must respond within 7 seconds, so the snippet waits 5 seconds and then lets the signup through.

You need an API key from the API keys page (Pro, Business or Agency), stored server-side as SIMPLEVERIFIER_KEY. Reviewed 2026-10-31.

Block, warn or allow

  • Block: disposable or temporary inbox (reason_code "disposable" (action "do_not_send"))
  • Block: invalid: bad format, no mail server, mailbox not found (action "do_not_send" (reason_code "syntax", "no_mx", "mailbox_not_found", "domain_not_hosted"))
  • Block: misspelled domain (gmial.com, hotmial.com) (reason_code "typo_domain" (action "do_not_send"))
  • Warn: role inbox (info@, sales@, admin@) (reason_code "role")
  • Warn: catch-all domain (reason_code "catch_all")
  • Warn: looks invented (test@, john.doe@, example.com) (likely_invented true)
  • Allow: valid, or the check could not finish (action "send", "send_with_care", "retry_later", or any non-2xx answer or timeout)

The reasoning for each row is on the signup protection overview.

  1. 1. Before you start

    Blocking functions need Firebase Authentication with Identity Platform enabled on the project. Store the key as a secret, not in code.

    firebase functions:secrets:set SIMPLEVERIFIER_KEY
  2. 2. Add the guard helper

    One file, no dependencies, never throws. It blocks only what the table above says to block and lets every unfinished check through.

    functions/src/signup-guard.ts
    // signup-guard.ts: server code only. Needs fetch and AbortSignal.timeout (Node 18+, Deno, Workers, Bun).
    export type Decision = "block" | "warn" | "allow";
    
    export interface Verdict {
      decision: Decision;
      /** Stable SimpleVerifier reason_code, or null when the check didn't finish. */
      reasonCode: string | null;
      /** Message to show the person when decision is "block". */
      message: string | null;
    }
    
    interface Options {
      apiKey?: string;
      /** Keep it under your platform's hook limit. A timeout lets the signup through. */
      timeoutMs?: number;
      /** Log what would be blocked, block nothing. Run this way for a few days first. */
      dryRun?: boolean;
    }
    
    const API_URL = "https://simpleverifier.com/api/v1/verify";
    const WARN_CODES = new Set(["role", "catch_all"]);
    const ALLOW: Verdict = { decision: "allow", reasonCode: null, message: null };
    
    function blockMessage(reasonCode: string, reason: string): string {
      if (reasonCode === "disposable") return "Please use a permanent email address, not a temporary inbox.";
      const hint = reason.match(/did you mean ([^?\s]+)\??/i)?.[1];
      if (hint) return `Check the spelling of your email address. Did you mean ${hint}?`;
      return "This email address can't receive mail. Please check it.";
    }
    
    export async function checkSignupEmail(email: string, options: Options = {}): Promise<Verdict> {
      const { apiKey = process.env.SIMPLEVERIFIER_KEY, timeoutMs = 8000, dryRun = false } = options;
      if (!apiKey) return ALLOW;
    
      let result: { action?: string; reason?: string; reason_code?: string; likely_invented?: boolean };
      try {
        const res = await fetch(API_URL, {
          method: "POST",
          headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
          body: JSON.stringify({ email: email.trim() }),
          signal: AbortSignal.timeout(timeoutMs),
        });
        if (!res.ok) return ALLOW; // 401, 402, 429, 503: fail open
        result = await res.json();
      } catch {
        return ALLOW; // timeout or network error: fail open
      }
    
      const reasonCode = result.reason_code ?? null;
      if (result.action === "do_not_send") {
        const verdict: Verdict = { decision: "block", reasonCode, message: blockMessage(reasonCode ?? "", result.reason ?? "") };
        if (!dryRun) return verdict;
        console.info("[signup-guard] dry run, would block", { reasonCode });
        return { ...verdict, decision: "warn" };
      }
      if (result.likely_invented || (reasonCode && WARN_CODES.has(reasonCode))) {
        return { decision: "warn", reasonCode, message: null };
      }
      return { decision: "allow", reasonCode, message: null };
    }
  3. 3. Write the blocking function

    The client receives the HttpsError as an error on createUserWithEmailAndPassword; catch it and show the message.

    functions/src/index.ts
    import { beforeUserCreated, HttpsError } from "firebase-functions/v2/identity";
    import { defineSecret } from "firebase-functions/params";
    import { checkSignupEmail } from "./signup-guard";
    
    const SIMPLEVERIFIER_KEY = defineSecret("SIMPLEVERIFIER_KEY");
    
    export const screensignup = beforeUserCreated({ secrets: [SIMPLEVERIFIER_KEY] }, async (event) => {
      const email = event.data?.email;
      if (!email) return; // phone or anonymous sign-in
    
      const verdict = await checkSignupEmail(email, {
        apiKey: SIMPLEVERIFIER_KEY.value(),
        timeoutMs: 5000, // blocking functions must answer within 7 s
        dryRun: process.env.SIGNUP_GUARD_DRY_RUN === "1",
      });
    
      if (verdict.decision === "block") {
        throw new HttpsError("invalid-argument", verdict.message ?? "Please use a different email address.");
      }
      if (verdict.decision === "warn") {
        return { customClaims: { emailCheck: "warn", emailReason: verdict.reasonCode } };
      }
      return;
    });
  4. 4. Deploy and register it

    After deploying, open Authentication > Settings > Blocking functions in the Firebase console and select screensignup for Before account creation if it isn't selected already.

    firebase deploy --only functions:screensignup

More on calling the API from this stack (retries, status handling, tests): API guide. Every field is described in the API docs.

Common questions

Does this run for Google sign-in too?
Yes, for every new account. Provider addresses are rarely disposable, so they almost always pass. To check only email and password sign-ups, return early unless event.additionalUserInfo?.providerId is "password".
How do I read the warn flag later?
Custom claims are on the ID token: request.auth.token.emailCheck in Security Rules, or getIdTokenResult().claims.emailCheck on the client.
Which plan includes the API?
Pro ($29.99/month, 10,000 calls a month included), Business and Agency. Each address checked is one call. Starter and Growth have no API access.
How do I try it without blocking anyone?
Set SIGNUP_GUARD_DRY_RUN=1. The code logs what it would block and lets everyone through. Read the logs for a few days, then remove the variable.

Other recipes

Clean your next list before it bounces

100 free email checks when you sign in with Google, no card. Then monthly plans from $9, or one flat price with fair use, never cut off.

100 free checks, no card Cancel anytime Secure payment by Stripe