# Check phone numbers in Firebase's beforeSmsSent function with Spaw

A beforeSmsSent blocking function that asks Spaw about the number and the visitor's IP, refuses codes your SMS policy would not send, and fails open.

Updated: 2026-09-27

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](/docs/phone-intelligence#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

1. 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.
2. Store it as a function secret with `firebase functions:secrets:set SPAW_KEY`.
3. 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.

```bash
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

```javascript
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](/privacy) 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](/guides/gate-sms-verification-codes-with-the-sms-policy) 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`](/docs/signals/phone/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](/guides/sms-pumping-what-the-default-sms-policy-lets-through) 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.

```javascript
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](/docs/api/phone-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](/docs/api/report-phone-feedback) 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:

```bash
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.

Reference: https://spaw.co/integrations/firebase-auth
