How to Block Disposable Emails in Firebase
by EmailGuard Engineering
Share this article
Enjoyed this article?
More notes on building products, infrastructure, and teams.
by EmailGuard Engineering
More notes on building products, infrastructure, and teams.
Block disposable emails in Firebase Authentication by throwing from a beforeUserCreated blocking function, before Firebase writes the user. Call GET /api/v1/emails/detect, reject when disposable is true, and return without throwing when the call times out or returns a non-2xx status. Firebase requires the function to finish within 7 seconds. Abort detect at 3 seconds so a slow lookup cannot spend the whole budget.
This is the Firebase companion to block disposable emails at signup. Better Auth and Clerk live in their own doc. Supabase lives in the Auth hook doc. The status codes on a failed detect call are in how to handle email validation API errors.
beforeUserCreated, not after the user row exists.relay_domain and public_domain out of the disposable branch.auth/internal-error on the client, which is what Firebase documents for a thrown blocking error.Prerequisites: Firebase Authentication with Identity Platform. Firebase's blocking-functions guide says you must upgrade before these triggers run. A key with email:detect, stored as a function secret. See creating API keys. The key never ships in the Android, iOS, or web app.
onCreate is too latebeforeUserCreated runs before the user is saved and before a token goes back to the client. An onCreate trigger runs after that save. A public gist that calls a disposable API from auth.user().onCreate and then updateUser({ disabled: true }) disables a user who already exists. The confirmation mail may already be queued. Delete-after-the-fact is cleanup. It is not the gate.
Firebase documents a second trigger, beforeUserSignedIn, and says creating a user fires both. Put the disposable block only in beforeUserCreated. A later sign-in should not depend on a second detect call, and it should not lock out a person whose address was accepted last month.
Anonymous auth and custom auth do not run blocking functions. If event.data.email is missing, return. Phone sign-in with no email is that path.
HttpsError comes from firebase-functions/v2/identity, same module as beforeUserCreated. Firebase's sample uses invalid-argument to refuse an address.
import { beforeUserCreated, HttpsError } from "firebase-functions/v2/identity";
const DETECT_URL = "https://emailguard.co/api/v1/emails/detect";
async function detectDisposable(email: string): Promise<"block" | "allow"> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 3000);
try {
const res = await fetch(
`${DETECT_URL}?email=${encodeURIComponent(email)}`,
{
headers: {
Authorization: `Bearer ${process.env.EMAILGUARD_API_KEY}`,
},
signal: ctrl.signal,
},
);
if (!res.ok) return "allow";
const body = (await res.json()) as {
data?: { disposable?: boolean };
};
return body.data?.disposable ? "block" : "allow";
} catch {
return "allow";
} finally {
clearTimeout(timer);
}
}
export const blockDisposableBeforeCreate = beforeUserCreated(async (event) => {
const email = event.data?.email;
if (!email) return;
const verdict = await detectDisposable(email);
if (verdict === "block") {
throw new HttpsError(
"invalid-argument",
"Use a permanent email address.",
);
}
});Set EMAILGUARD_API_KEY with Firebase's secret or environment config for functions. Do not commit it. A 401 from a missing key hits the !res.ok branch and allows signup. That is fail open for the account, and it is an ops page, not a copy change on the form. The two 429 codes, per-minute and monthly quota, take the same allow branch. Do not retry them inside this function. The policy write-up is fail open vs fail closed.
disposable: true is the only throw. relay_domain is Apple Hide My Email and the other relays in privacy relay vs disposable email. public_domain is Gmail and Outlook.com, which you block only when the product asked for a work email. Mixing those flags into this if rejects people who can still reset a password.
Deploy with the Firebase CLI:
firebase deploy --only functions:blockDisposableBeforeCreateFirebase says the function must respond within 7 seconds or the client operation fails. The 3 second abort is inside that cap. Cold start sits in the same 7 seconds. If the instance is cold and detect is slow, you can still miss the deadline and Firebase fails the signup for you. That failure is Firebase's, after our abort. Watch function latency before you turn the block on for every tenant.
Firebase's guide says a thrown HttpsError is returned to the client as an internal error. Their sample catches auth/internal-error and looks for Cloud Function in error.message. Plan on that shape. Do not wait for auth/invalid-email. That code is Firebase's own format check.
import { createUserWithEmailAndPassword, getAuth } from "firebase/auth";
const auth = getAuth();
try {
await createUserWithEmailAndPassword(auth, email, password);
} catch (error) {
const code = error && error.code;
const message = error && error.message ? String(error.message) : "";
if (
code === "auth/internal-error" &&
message.includes("permanent email")
) {
showFormError("Use a permanent email address.");
return;
}
showFormError("Could not create the account. Try again.");
}Keep the phrase in the HttpsError message stable so the client check has a string to find. If a future Firebase release stops copying that sentence into error.message, you still have auth/internal-error from this flow. Log the raw code and message once in development and confirm the phrase is there before you ship the copy.
Run these against a staging project that has Identity Platform and the function deployed.
user@mailinator.com or another known throwaway. The client takes the permanent-email branch. The Firebase user does not exist afterward.user@gmail.com. The account is created. Gmail is not disposable.disposable stays false.Quota for repeated tests is on pricing. Cache is optional here. One signup is one detect call. If you also hang a beforeUserSignedIn function that calls detect, you pay twice on every registration. Don't.
Disabling the user in onCreate. The row exists. You are late.
Throwing on every non-2xx. A 401 or 429 becomes "your email is bad" for every signup until someone fixes the key or the plan. Allow, and alert.
Blocking relay_domain in the same if as disposable. Relays are durable forwarding addresses. The disposable flag is the throwaway domain.
Putting the API key in VITE_ or NEXT_PUBLIC_ config next to the Firebase web config. The blocking function is the server. The web app only catches the error.
Copying a domain-suffix sample as if it were disposable detection. Firebase's own sample checks email.includes('@acme.com'). That is an allowlist for one company. It does not know about Mailinator.
Returning { disabled: true } from the blocking function. Firebase lets a handler return a partial user and still create the account. A disabled user is a user. Throw HttpsError when the address is disposable so the write does not happen. Use the return value for claims and display fields you want on a user you are willing to keep.
The detect body already carries relay_domain and public_domain next to disposable. Read them in this function if the product needs a second rule. Do not call detect again.
const body = (await res.json()) as {
data?: {
disposable?: boolean;
relay_domain?: boolean;
public_domain?: boolean;
};
};A throwaway domain is the HttpsError. A relay is a log line or a product flag, and the function returns. A public-domain address is a work-email decision from require a work email at signup, not a Firebase default. Teams that want Gmail blocked on one form and allowed on another should not hard-code that inside this single project-wide trigger. Firebase says blocking functions apply to all users in the project, including tenants. A tenant id is on the event if you need to branch. One global "no Gmail" rule will also hit the tenant that sells to consumers.
New Google and Apple sign-ins that create a user run beforeUserCreated too. The email on that event is whatever the identity provider gave Firebase. Classify it the same way. Do not special-case providerData unless you have a written exception for a provider you trust.
On the allow path that came from a failed call, log the HTTP status or abort, the Firebase event.data.uid if it is already present, and the domain only. The domain is enough to see a bad key versus a timeout. The full address in Cloud Logging is a mailbox you now have to retain. On the block path, log the domain and disposable: true. Skip the local part.
That log is how you tell a real attack from the morning the secret rotated and every signup fail-opened. Pair it with the API error map so a 429 quota event pages billing, and a 401 pages whoever owns the secret.
Upgrade to Identity Platform, deploy beforeUserCreated from firebase-functions/v2/identity, and throw HttpsError with invalid-argument when detect returns disposable: true. Return normally when detect times out.
The blocking-functions sample in the official guide filters on a domain suffix such as @acme.com. There is no disposable list in that sample. You bring the check.
Firebase Authentication returns an error and the client operation fails. Abort the detect call at 3 seconds and allow the signup on abort, so a slow classification does not become a failed registration.
beforeUserSignedIn too?Use it for rules that apply to every sign-in. The disposable block belongs in beforeUserCreated, once, before the user exists. Creating a user already triggers both functions. A second detect call there doubles the quota and can lock someone out later.
auth/invalid-email?Firebase documents the client error as auth/internal-error. Look for that code, and for the phrase you put in the HttpsError message.