Blocking disposable emails with Firebase Auth blocking functions
· 6 min read · Tutorials
To block disposable emails in Firebase Auth, deploy a beforeUserCreated blocking function that checks event.data.email against an email-classification API and throws an HttpsError when the address is disposable or machine-generated. Firebase runs the function before the user is saved, so a thrown error stops the account from ever existing. You need Firebase Authentication with Identity Platform enabled and a project on the Blaze plan, and the function has 7 seconds to answer.
Requirements before you write code
From Firebase's blocking functions guide:
- Identity Platform. Blocking functions are only available after you upgrade the project to Firebase Authentication with Identity Platform. The upgrade changes how Auth is billed, so read the pricing page before you click it.
- Blaze plan. Blocking functions are Cloud Functions, and deploying Cloud Functions requires the Blaze plan.
- 7-second limit. If the function doesn't respond in time, Firebase Auth returns an error and the client operation fails. Cold starts count.
- What triggers it.
beforeUserCreatedfires before a new user is saved, for email/password and federated providers alike. Anonymous and custom-token authentication don't trigger blocking functions.
Creating a user triggers both beforeUserCreated and beforeUserSignedIn. Put the check in beforeUserCreated so it runs once per account, not on every login.
What to reject
The function calls pingutil's /v1/lookup with skip_enrichment: true. That returns category, heuristic email_signals and a verdict from lists and heuristics, without the slower company enrichment step.
- Reject when
verdict.is_spamis true. That covers disposable domains, reputation-flagged domains and randomized local parts likeccjxx90sze@. - Allow
verdict.low_trustaddresses (info@,support@,sales@). Real customers sign up with shared inboxes. - Allow privacy relays such as Apple Hide My Email. They forward to a real inbox; relay services are not disposable email explains why.
The function
In a Firebase project with TypeScript functions (firebase init functions), replace functions/src/index.ts:
// functions/src/index.ts
import { beforeUserCreated, HttpsError } from 'firebase-functions/v2/identity';
import { defineSecret } from 'firebase-functions/params';
import * as logger from 'firebase-functions/logger';
const PINGUTIL_KEY = defineSecret('PINGUTIL_KEY');
// Blocking functions must answer within 7 seconds, cold start included.
const LOOKUP_TIMEOUT_MS = 2500;
const FAIL_CLOSED = process.env.EMAIL_CHECK_FAIL_CLOSED === 'true';
// Per instance. Stops a rate-limited key from being hit on every signup.
let backoffUntil = 0;
type Verdict = { is_spam: boolean; low_trust: boolean; reason: string | null };
type Screen =
| { kind: 'checked'; verdict: Verdict; category: string }
| { kind: 'invalid' }
| { kind: 'unavailable'; why: string };
async function screenEmail(email: string, apiKey: string): Promise<Screen> {
if (Date.now() < backoffUntil) return { kind: 'unavailable', why: 'rate_limited' };
let res: Response;
try {
res = await fetch('https://pingutil.com/v1/lookup', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ input: email, skip_enrichment: true }),
signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS),
});
} catch (err) {
const why = err instanceof Error && err.name === 'TimeoutError' ? 'timeout' : 'network';
return { kind: 'unavailable', why };
}
if (res.status === 429) {
const body = (await res.json().catch(() => ({}))) as { retry_after_seconds?: number };
backoffUntil = Date.now() + (Number(body.retry_after_seconds) || 60) * 1000;
return { kind: 'unavailable', why: 'rate_limited' };
}
if (res.status === 400) return { kind: 'invalid' };
if (!res.ok) return { kind: 'unavailable', why: `http_${res.status}` };
const data = (await res.json()) as { verdict: Verdict; category: string };
return { kind: 'checked', verdict: data.verdict, category: data.category };
}
export const screensignup = beforeUserCreated({ secrets: [PINGUTIL_KEY] }, async (event) => {
const email = event.data?.email?.trim().toLowerCase();
// Phone sign-ups have no email. Anonymous and custom-token auth never reach this function.
if (!email) return;
const screen = await screenEmail(email, PINGUTIL_KEY.value());
if (screen.kind === 'invalid') {
throw new HttpsError('invalid-argument', 'Please enter a valid email address.');
}
if (screen.kind === 'checked') {
if (screen.verdict.is_spam) {
logger.info('signup blocked', { reason: screen.verdict.reason, ip: event.ipAddress });
throw new HttpsError(
'permission-denied',
screen.verdict.reason === 'disposable_domain'
? 'Temporary email addresses are not allowed. Please use a permanent address.'
: 'This email address cannot be used to sign up.',
);
}
return;
}
logger.warn('email check unavailable', { why: screen.why, email });
if (FAIL_CLOSED) {
throw new HttpsError('unavailable', 'Sign-ups are briefly unavailable. Please try again in a minute.');
}
});
This type-checks under strict against firebase-functions 7.x on a Node 20 or 22 runtime, both of which ship fetch and AbortSignal.timeout.
Returning nothing (undefined) lets the signup through unchanged. Throwing stops it. Every branch does one or the other on purpose, so the outcome never depends on how Firebase treats an unexpected exception or a timeout.
Store the key as a secret and deploy
firebase functions:secrets:set PINGUTIL_KEY
firebase deploy --only functions:screensignup
defineSecret plus secrets: [PINGUTIL_KEY] makes the key available to this function only, through Cloud Secret Manager. Don't put it in client config: Firebase client config is public by design, and anything in it is readable by anyone who loads your app.
Deploying is all it takes to register the function with Auth. Removing it is the trap: the docs warn that if you delete a blocking function, you also need to unregister it from Firebase Authentication, or users may be unable to sign in at all. Check Authentication > Settings > Blocking functions in the console after any rename or delete.
Showing the error on the client
Firebase wraps the HttpsError and returns it to the client as an internal error, so createUserWithEmailAndPassword rejects with auth/internal-error. Firebase's own client example checks for that code and looks for "Cloud Function" in the message to tell a blocked signup from other failures. Do the same, and show one message that covers every rejection reason:
import { getAuth, createUserWithEmailAndPassword } from 'firebase/auth';
import { FirebaseError } from 'firebase/app';
export async function signUp(email: string, password: string): Promise<string | null> {
try {
await createUserWithEmailAndPassword(getAuth(), email, password);
return null;
} catch (err) {
if (
err instanceof FirebaseError &&
err.code === 'auth/internal-error' &&
err.message.includes('Cloud Function')
) {
return 'This email address cannot be used. Temporary addresses are not accepted.';
}
return 'Sign-up failed. Please try again.';
}
}
Matching on message text is fragile, so test it after SDK upgrades. If you want a nicer experience, call a lightweight server endpoint for inline feedback while the user types (see real-time email feedback in a React form) and keep the blocking function as the enforcement point.
Why 2.5 seconds and why fail open
Timeout. The function has 7 seconds end to end. A cold start on a Node runtime plus secret loading eats part of that. In skip_enrichment mode the lookup normally finishes far inside 2.5 seconds, so the timeout only fires when something is wrong, and it leaves headroom.
429. pingutil allows 60 requests per minute per key, and the 429 body carries "retry_after_seconds": 60. Waiting that long would break the 7-second limit, so the function records backoffUntil and skips the API for that instance until then. Each function instance keeps its own backoff, so several instances may each see one 429 before they all back off.
Fail open by default. If the check can't run, the signup goes through and the email is logged with the reason. Set EMAIL_CHECK_FAIL_CLOSED=true (for example in functions/.env) only if new accounts immediately get something worth abusing, like free credits. The trade-off is the same one covered in the Supabase version of this hook: fail closed and every signup fails during an outage.
Unchecked accounts can be re-screened later. A scheduled function can list recent users with the Admin SDK, re-run the lookup and disable the bad ones with updateUser(uid, { disabled: true }).
Limits
- The disposable list is a weekly-synced community blocklist. Brand-new throwaway domains get through until someone adds them; the randomized-local-part signal catches some of those.
- The randomness heuristic spares real names with digits, but some non-English names and relay aliases can be flagged. Review blocked-signup logs for the first couple of weeks.
- Email-link (passwordless) sign-in creates the user when the link is completed, so the check runs after the email has already been sent.
The Auth0 equivalent is checking emails in a pre-user-registration Action. Request and response fields are in the API reference.
Questions people ask
- Do Firebase Auth blocking functions need Identity Platform?
- Yes. Firebase's docs say you must upgrade the project to Firebase Authentication with Identity Platform before you can use blocking functions. Deploying the function itself also needs the Blaze plan, as with any Cloud Function.
- Does beforeUserCreated run for Google or other OAuth sign-ins?
- It runs whenever a new Firebase user is about to be created, which includes the first sign-in with an OAuth provider. Anonymous sign-in and custom token authentication do not trigger blocking functions.
- What error does the client see when a blocking function rejects a signup?
- Firebase wraps the HttpsError and returns it to the client as an internal error, so the client receives auth/internal-error. Firebase's own example checks the error message text to tell a blocked signup apart from other failures.