Most Firebase security work is about reads a user should not have been allowed to make. Sign-up abuse is the opposite problem: every request is legitimate as far as your rules are concerned. The attacker is using the front door, exactly as designed, a few hundred thousand times.
Two shapes show up in practice. One is SMS toll fraud, where someone pumps your phone sign-in endpoint to generate verification texts to numbers they earn a cut of. The other is bulk account creation — throwaway emails signing up for whatever your free tier gives away. The first shows up on the invoice within hours. The second shows up weeks later as a Firestore bill, a Gemini bill, or a support queue.
Why phone auth is the expensive one
Every signInWithPhoneNumber call that passes the client-side checks sends a real SMS through a real carrier, and you pay for it. There is no cache, no free tier worth leaning on, and the per-message price varies by destination country by more than an order of magnitude. An attacker picks the expensive destinations.
The arithmetic is worth doing once, because it changes how urgently teams treat this. A script that sends one verification code per second for a weekend is roughly 170,000 messages. At a cent each that is a mildly annoying $1,700. At the rates for some international destinations it is a five-figure line item on a product with 200 real users. The attack does not need to break anything. It needs your endpoint to stay open.
The tell in the data is simple: verification codes sent far outnumber codes confirmed, and the destination country mix does not look like your user base. Check both in the Firebase console's Auth usage view before you assume you are fine.
The four controls, in the order they pay off
1. Region allowlist
If you sell to US and Canadian customers, phone sign-in should not be able to reach a carrier in another hemisphere. Firebase Auth with Identity Platform supports allow and deny lists of calling regions for SMS. This is one configuration change, it is free, and it eliminates most of the economic motive in a single move. Do it first.
Keep it narrow and revisit it when you open a new market. A support ticket from a real customer who moved abroad is a cheaper failure than an open endpoint.
2. SMS quotas and per-number limits
Set a daily cap on SMS volume that sits comfortably above your real sign-up rate and far below your pain threshold. The cap is not a security control — it is a blast radius control. It converts an unbounded bill into a bounded one and an incident into a degraded-service morning.
Pair it with a per-number and per-IP rate limit at your own edge if you have one. The identity provider's limits are generous by design.
3. reCAPTCHA Enterprise on the sign-in path, App Check on everything else
These two get confused constantly, so: App Check attests that the app is genuine. reCAPTCHA Enterprise scores whether the actor looks human. Abuse prevention for phone auth needs the second one, and Firebase Auth with Identity Platform can enforce it on phone, email/password, and email enumeration paths directly.
Roll it out the same way we roll out App Check — audit mode first, read the score distribution for a week, then enforce. Flipping straight to enforcement on a live auth flow is how you lock out the fraction of real users on hardened browsers.
We have written up the staged App Check rollout separately; the sequencing logic is identical and the mistake is the same one, which is enforcing before you have a baseline.
4. Blocking functions
Everything above is configuration. Blocking functions are where your own judgment goes.
A blocking function is a Cloud Function that runs synchronously inside the auth flow — beforeUserCreated before the account exists, beforeUserSignedIn on every sign-in — and can reject the operation or attach claims to it.
const { beforeUserCreated, HttpsError } = require('firebase-functions/v2/identity');
exports.gateSignUp = beforeUserCreated(
{ region: 'us-central1' },
async (event) => {
const email = event.data?.email?.toLowerCase();
// Corporate product: no consumer or disposable domains.
if (email && DISPOSABLE_DOMAINS.has(email.split('@')[1])) {
throw new HttpsError('invalid-argument', 'Use your work email address.');
}
// Low reCAPTCHA score plus an unverified email is not a customer.
const score = event.additionalUserInfo?.recaptchaScore ?? 1;
if (score < 0.4 && !event.data?.emailVerified) {
throw new HttpsError('permission-denied', 'Sign-up blocked.');
}
// Stamp the tenant at creation so rules never have to guess.
return { customClaims: { plan: 'free', createdVia: event.eventType } };
}
);
Three constraints matter before you put one in the path:
- It is on the critical path. A cold start here is a cold start on every sign-up. Set minimum instances, or accept that your worst login latency is whatever that function's p99 is.
- It has a hard timeout. Blocking functions must return within seconds. Do not call a third-party enrichment API from inside one. Do the enrichment after the fact, from an Auth-triggered background function, and disable the account later if it fails.
- A thrown error is a user-visible error. Write the message for the human who will read it, and log the real reason separately.
Returning customClaims from beforeUserCreated is also the cleanest place to establish a tenant or plan claim, because the claim exists before the user's first token is minted. That removes the awkward window where a brand-new user has a valid session and no role, which is the window most custom-claims bugs live in.
What this does to the bill, in both directions
Two cost effects run in opposite directions, and you should price both.
Abuse prevention and the controls above are features of Firebase Auth with Identity Platform, which bills per monthly active user above a free allowance rather than being bundled. For a consumer app with a large, low-value user base, that upgrade is a real recurring number — run it against your actual MAU before you assume it is noise.
Against that: blocked sign-ups are not only cheaper SMS. Every junk account that never gets created is a user document never written, an onboarding function never invoked, a welcome email never sent, and a row that never lands in whatever you pay for per seat. On the engagements where we have measured it, the downstream savings from rejecting throwaway accounts tend to be larger than the SMS line that triggered the work.
The checks we run on an audit
- Phone sign-in enabled but never used by the product — if it is dead code, turn the provider off.
- Region allowlist configured and matching the markets you actually sell to.
- Daily SMS quota set to a number someone chose, not left at the default.
- reCAPTCHA Enterprise enforcement state, and whether anyone looked at the score distribution before enforcing.
- Sent-versus-confirmed verification ratio over the last 90 days, by country.
- Email enumeration protection enabled, so the sign-in endpoint stops confirming which addresses exist.
- Blocking functions, if present: minimum instances set, no external network calls, no silent failures.
- Anonymous auth accounts counted and expired — they are cheap to create, and most apps that enable anonymous auth never clean them up.
None of these are hard. The reason they are missing is that auth gets configured once, during the week the product first works, and then nobody touches it until the invoice arrives.
If your Auth usage graph has a shape you cannot explain, send it over with the destination-country breakdown. That is usually a one-afternoon diagnosis.