Firebase Authentication runs a beforeSmsSent blocking function before it sends a phone sign-in or multi-factor code, and a function that throws an HttpsError stops that SMS. Asking Spaw about the number inside it means a code goes out only where your account's SMS policy answers ok_to_send: true: not to a number an SMS-receiving website publishes, not to a range reserved for fiction, not to a number your own reports marked as abused. Firebase still sends the message itself; the function only decides whether it goes.
What the function receives
These facts come from Firebase's blocking-functions pages (last updated 24 September 2026) and the type definitions in firebase-functions 7.4.0, both read on 27 September 2026.
| Item | What Firebase provides |
|---|---|
| Trigger | beforeSmsSent from firebase-functions/v2/identity, Node.js only |
| Prerequisite | The project upgraded to Firebase Authentication with Identity Platform |
| The number | event.additionalUserInfo.phoneNumber |
| The visitor's address | event.ipAddress, the address of the device the person is signing in from |
| The flow | event.smsType: SIGN_IN_OR_SIGN_UP, MULTI_FACTOR_ENROLLMENT or MULTI_FACTOR_SIGN_IN |
| Refusing | Throw an HttpsError; Firebase wraps it and returns it to the client app as an internal error (auth/internal-error), which the screen that requests the code should catch |
| Time limit | "Your function must respond within 7 seconds. After 7 seconds, Firebase Authentication returns an error, and the client operation fails." |
The last row matters most. A check that hangs does not let the code through; it breaks the sign-in. The sample gives Spaw 2.5 seconds and sends the code unchecked when no answer arrives in that time.
Before you start
- Create a secret API key on the API keys page of your Spaw dashboard. Scope it to phone and give it a daily credit cap: the cap bounds what a flood of attempts can spend on lookups. Once the cap is spent the function sends codes unchecked, so set it above a normal day's sign-ups.
- Store it as a function secret with
firebase functions:secrets:set SPAW_KEY. - Switch the SMS policy's opt-out rule off, on the SMS policy tab of the phone verification page or with one request. By default a number your suppression list holds as opted out is refused, and a person who once replied STOP to a marketing message could no longer sign in.
curl -X PUT https://spaw.co/api/v1/phone/policy \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{"block_opted_out": false}'
An account has one policy, and every lookup it makes applies it. With the rule off, an opted-out number is decided like any other and the answer still says meta.opted_out: true, so a marketing flow on the same account reads that field itself.
The function
const { beforeSmsSent, HttpsError } = require("firebase-functions/v2/identity");
const { defineSecret } = require("firebase-functions/params");
const logger = require("firebase-functions/logger");
const SPAW_KEY = defineSecret("SPAW_KEY");
// Codes that attach a number to an account. A multi-factor sign-in code goes
// to a number the user enrolled earlier, so it is logged and never refused.
const REFUSE_ON = new Set(["SIGN_IN_OR_SIGN_UP", "MULTI_FACTOR_ENROLLMENT"]);
exports.checknumberbeforesms = beforeSmsSent({ secrets: [SPAW_KEY] }, async (event) => {
const phone = event.additionalUserInfo?.phoneNumber;
if (!phone) {
return;
}
let answer;
try {
const response = await fetch("https://spaw.co/api/v1/phone", {
method: "POST",
headers: {
Authorization: `Bearer ${SPAW_KEY.value()}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(event.ipAddress ? { phone, client_ip: event.ipAddress } : { phone }),
signal: AbortSignal.timeout(2500),
});
if (!response.ok) {
logger.warn(`Spaw answered ${response.status}; the code goes out unchecked`);
return;
}
answer = (await response.json()).data;
} catch (error) {
logger.warn("Spaw did not answer in time; the code goes out unchecked", error);
return;
}
// opted_out is decided before every rule except invalid, so it says nothing
// about the rest: switch block_opted_out off rather than refuse a code on it.
if (answer.ok_to_send || answer.blocked_by === "opted_out") {
return;
}
logger.info("Spaw would refuse this code", { blocked_by: answer.blocked_by, sms_type: event.smsType });
if (REFUSE_ON.has(event.smsType)) {
throw new HttpsError("permission-denied", "This number cannot receive a verification code.");
}
});
Deploy it with firebase deploy --only functions. The request carries two fields, the number and the visitor's address, and nothing else about the person. client_ip is counted, never looked up and never billed; the privacy policy describes how the count is kept. The sample follows Firebase's documented signature; run it in your own project before you rely on it.
Which codes it refuses, and which it only logs
REFUSE_ON holds the flows where a number is being attached to an account: a phone sign-up or sign-in, which Firebase reports as one type, and a multi-factor enrolment. A multi-factor sign-in sends a code to a number the user enrolled earlier, and refusing it would lock an existing customer out, so the function logs what Spaw said and lets the code go. Add MULTI_FACTOR_SIGN_IN to the set only if you would rather send such a user to support. Because sign-in and sign-up share a type, a returning user whose number has since appeared on an SMS-receiving website is refused as well; anyone can read the codes such a site publishes, so that is usually the side to err on.
Under the published defaults a refusal names one of these in blocked_by: invalid, fictional (a range reserved for fiction, such as the North American 555-01XX block), disposable (published by an SMS-receiving website), premium_rate, reported_abuse (your own abuse reports), reported_abuse_widely (three or more accounts within 30 days), or risk_score (a score above 60). Rules you add answer country or line_type. The gate-SMS guide explains each rule and the order they apply in.
The visitor's address adds what the number alone cannot show. Five distinct numbers tried from one address within 60 minutes add numbers_per_client (+30), unless Spaw's IP data places the address on a mobile carrier or a corporate proxy, where many people share one address: those addresses still answer the count but never add the signal, and on a sign-in screen many visitors are on mobile data. Five distinct numbers your account looks up in one 1,000-number range within 60 minutes add range_burst (+40), whatever the address. Neither passes the default ceiling of 60 on its own. Together they score 70, and so does numbers_per_client beside a number in a block allocated to a virtual-number wholesaler (virtual, +40): the answer is then ok_to_send: false with blocked_by: "risk_score". The SMS pumping guide shows what the defaults let through and the request that tightens them.
Why it fails open
Every outcome that is not an answer ends in return: a timeout, a network error, 402 INSUFFICIENT_CREDITS when the balance is empty, 429 when the key's daily cap is spent or your phone requests exceed 50 a second, a 5xx. The code then goes out exactly as it did before the function existed. Failing closed would turn a Spaw outage or an empty balance into an outage of your sign-in. The price is that an attack arriving during one of those failures is not filtered, which is why each one is logged as a warning: alert on them.
Firebase's own controls stack with this check. Its phone-auth guide asks for an SMS region policy, the countries Firebase may send codes to ("Setting an SMS region policy can help protect your apps from SMS abuse. For new projects, the default policy allows no regions."), and when reCAPTCHA Enterprise protects the project the event carries additionalUserInfo.recaptchaScore, a reading of the request rather than the number. The Spaw policy can hold a country allow-list too; keep the two lists the same, or leave allowed_countries unset and let Firebase's region policy be the one source of truth.
Report the codes people entered
A code that was delivered and never entered is what SMS pumping looks like from the sender's side. Report every code that was entered as a verified outcome: it cancels an earlier failure reported for the number and clears a suppression entry your failure reports added. Firebase checks the code itself, so the report comes from your backend: an ID token whose firebase.sign_in_provider is phone was issued after the code was entered, and its phone_number claim holds the number.
const { getAuth } = require("firebase-admin/auth");
async function reportVerifiedSignIn(idToken) {
const token = await getAuth().verifyIdToken(idToken);
if (token.firebase.sign_in_provider !== "phone" || !token.phone_number) {
return;
}
try {
await fetch("https://spaw.co/api/v1/phone/feedback", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SPAW_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ items: [{ phone: token.phone_number, outcome: "verified" }] }),
signal: AbortSignal.timeout(2500),
});
} catch {
// A missed report only means one less outcome on your account.
}
}
module.exports = { reportVerifiedSignIn };
Call it once, when your server creates its session from a new sign-in, not on every request the token authorises. A multi-factor sign-in is marked by firebase.sign_in_second_factor: "phone"; that token names the factor's uid rather than its number, and the Admin SDK's getUser() lists the number under multiFactor.enrolledFactors. Firebase sends the SMS through its own provider, so there are no delivery receipts to forward: verified is the one outcome this setup can report. With verified alone the feedback summary reads verified_share: 1 and lists no blocks, because both are worked out against delivery reports. To read a conversion rate, compare the verified count with the number of codes the function let through, which you can log on its way out. The report endpoint is free.
Try the branches without Firebase
The documented test numbers answer a fixed result each, cost nothing and are never counted in your traffic, so each branch can be seen with curl before the function is deployed:
curl -X POST https://spaw.co/api/v1/phone \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{"phone": "+12025550103", "client_ip": "203.0.113.7"}'
Under the default policy +1 202 555 0103 answers ok_to_send: false with blocked_by: "disposable", +1 202 555 0105 answers blocked_by: "fictional", and +1 202 555 0102 (the test number that answers the virtual signal, score 40) and +1 202 555 0100 answer ok_to_send: true.
What to leave out
Leave hlr out of the request. The live carrier-network check (hlr: true) is not enabled on the service: hlr_checked answers false and nothing extra is charged. Leave the policy's require_reachable rule off for the same reason: while the check is not enabled, no lookup answers ok_to_send: true under it: every number that passes the other rules answers blocked_by: "unconfirmed", so the function would refuse every phone sign-in, sign-up and enrolment code. Send nothing but the number and the address: not the user's email, uid or display name.
ok_to_send is a fraud and deliverability decision. It is not consent and not a do-not-call scrub, and whether the person agreed to hear from you stays yours to record.
Cost
A lookup costs 1 credit when the number is valid. Invalid numbers, the test numbers and a repeat of the same number within seven days are free, so a resent code costs nothing. Outcome reports are free. Phone endpoints accept 50 requests a second. Spaw is not affiliated with Google or Firebase.