# Phone Sign-in

Let people sign in and sign up with a code sent to their phone by text message, WhatsApp or voice call, through your own SMS provider.

Phone sign-in lets people sign in with a 6-digit code sent to their phone, by text message, WhatsApp or a voice call. Someone without an account signs up the same way, then gives a name and an email. It runs on your hosted sign-in page, so there is nothing to change in your app.

Step by step, with screenshots: [Set up phone sign-in](/sign-in-setup/phone).

## Your provider, your bill

BuildBase has no messaging account of its own. Every code goes out through your provider: your own Twilio account, or a webhook of yours that hands the code to whichever provider you already use. Your provider bills you for each message. BuildBase never pays for, marks up or resells a message, and your plan carries no message quota.

| Provider                                           | Channels                                                           | Who makes and checks the code |
| -------------------------------------------------- | ------------------------------------------------------------------ | ----------------------------- |
| Twilio Verify                                      | Text message, WhatsApp, voice call                                 | Twilio                        |
| Twilio Messaging (a Messaging service or a number) | Text message only                                                  | BuildBase                     |
| Signed webhook                                     | Text message, WhatsApp and voice call, as your endpoint sends them | BuildBase                     |

Twilio Verify is the one to pick if you use Twilio: it covers all three channels, and Twilio makes and checks each code. WhatsApp through Twilio Messaging needs templates WhatsApp has approved in advance, so WhatsApp goes through Verify or the webhook.

**Which countries can receive codes is your provider's setting, not ours.** BuildBase accepts any valid phone number and does not filter by country or line type. With Twilio Verify, set the service's geo permissions to the countries you serve. Leaving every country open is how SMS pumping fraud, where a script requests codes to premium-rate numbers, turns into a bill.

Connecting a provider and adding the Phone method need a plan that includes phone sign-in codes. Without one, the console shows them locked.

## Setting it up

1. Open **User Management → Access → Authentication → SMS** and choose how codes are sent.
   - **Twilio**: your account SID and auth token, and a Verify service SID (recommended), or a Messaging service SID or one of your Twilio numbers to send from. The credentials are checked with Twilio before they are saved, and a refusal shows Twilio's own message. The auth token is stored encrypted and is never shown again in full: the form shows a masked preview, and leaving the field blank keeps it.
   - **Webhook**: the HTTPS address of your endpoint. The first save makes a signing secret and shows it once, with a copy button. Put it in your endpoint's configuration before you send a test. **Rotate secret** makes a new one and shows it once; the old one stops working at once.
2. Set the **daily cap** and the share of it at which you are warned. See [Limits](#limits).
3. **Send a test** to your own number from the same tab. It is a real message on your provider's bill and counts toward the cap. If your provider refuses it, the card shows why and the [delivery log](#the-delivery-log) keeps the reason. A test that never reaches a provider says so instead: `429 daily_cap_reached` once the day's cap is used up, and `409 sms_unavailable` while no provider is connected.
4. Open the **Methods** tab, choose **Add auth method**, then **Phone**, and give it a title. Tick it on your client in the **Settings** tab. Until a provider is connected, the method is marked unavailable and no sign-in page offers it.
5. Back on the **SMS** tab, under **Phone sign-in**, choose the **default country** and the **ways to send the code**. The default country is where the picker on the sign-in page starts, and how a number typed without a country code is read. The ways to send are narrowed to what your provider can send; with more than one, people choose on the sign-in page.

**Disconnect** removes the provider. Phone codes stop at once: the sign-in page stops offering phone sign-in, and text messages stop working as a second step until a provider is connected again.

## The signed webhook

For each code, BuildBase sends your endpoint a `POST` with a JSON body, waits up to 5 seconds, and does not retry. Someone is waiting on the sign-in page, and a code that arrives a minute later is no use to them.

```json
{
  "to": "+14155550123",
  "code": "123456",
  "channel": "sms",
  "locale": "en",
  "purpose": "sign-in",
  "test": false
}
```

| Field     | What it holds                                                                                                   |
| --------- | --------------------------------------------------------------------------------------------------------------- |
| `to`      | The number, in E.164 form.                                                                                      |
| `code`    | The 6-digit code. Send it as it is, and never log it.                                                           |
| `channel` | `sms`, `whatsapp` or `voice`.                                                                                   |
| `locale`  | The person's language, for the message's wording, as a BCP 47 tag such as `en` or `pt-BR`.                      |
| `purpose` | `sign-in`, `sign-up`, `phone-add` (a person adding a number to their account), `mfa` (a second step) or `test`. |
| `test`    | `true` for a test sent from the console. It signs nothing in.                                                   |

It is signed the way BuildBase's [event webhooks](/webhooks/overview) are, with the secret from the SMS tab:

| Header                  | Contents                                                                                 |
| ----------------------- | ---------------------------------------------------------------------------------------- |
| `x-buildbase-timestamp` | Unix seconds, when the request was made                                                  |
| `x-buildbase-signature` | `sha256=<hex>`, the HMAC-SHA256 of `<timestamp>.<raw body>` with your SMS signing secret |

What your endpoint answers decides what happens next:

| Your answer                        | What BuildBase does                                                                                                    |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Any `2xx`                          | The code was sent.                                                                                                     |
| `422` with `{ "reason": "..." }`   | The number was refused. The sign-in page says the number cannot receive codes, and the delivery log keeps your reason. |
| Anything else, or no answer in 5 s | A failure. The sign-in page says codes cannot be sent right now and suggests another way to sign in.                   |

The SDK's `verifyWebhookSignature` checks the signature, in constant time, and refuses a request more than five minutes old, so a captured request cannot be replayed. Verify the raw body, before parsing it:

```typescript
import { verifyWebhookSignature } from '@buildbase/sdk';

export async function POST(req: Request) {
  const body = await req.text();

  const valid = verifyWebhookSignature({
    body,
    signature: req.headers.get('x-buildbase-signature'),
    timestamp: req.headers.get('x-buildbase-timestamp'),
    secret: process.env.BUILDBASE_SMS_SECRET!,
  });
  if (!valid) return new Response('Invalid signature', { status: 401 });

  const { to, code, channel } = JSON.parse(body);
  const sent = await sendWithYourProvider({ to, channel, code });

  // A number your provider will not deliver to: say so, with a reason.
  if (!sent.ok) return Response.json({ reason: sent.reason }, { status: 422 });
  return Response.json({ sent: true });
}
```

Without the SDK, Node's `crypto` does the same:

```typescript
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifySmsWebhook(
  rawBody: string,
  timestamp: string | undefined,
  signature: string | undefined,
  secret: string
): boolean {
  if (!timestamp || !signature) return false;
  // Refuse anything older than five minutes.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false;

  const expected =
    'sha256=' +
    createHmac('sha256', secret)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex');
  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Webhook sends do not count toward your plan's webhook deliveries, and BuildBase never stores the code in a delivery record.

## Limits

The per-number and per-address limits are counted before the number is looked up, so a number with an account and one without run out alike. These limits apply on every install, whatever its other rate-limit settings.

| Limit                | Default | Change it with          |
| -------------------- | ------- | ----------------------- |
| Per number, cooldown | 30 s    |                         |
| Per number, an hour  | 5       | `SMS_NUMBER_HOURLY_MAX` |
| Per number, a day    | 10      | `SMS_NUMBER_DAILY_MAX`  |
| Per address, an hour | 20      | `SMS_IP_HOURLY_MAX`     |
| Per address, a day   | 50      | `SMS_IP_DAILY_MAX`      |
| Per org, a day       | 1,000   | the console's daily cap |

The four `SMS_*` names are environment variables on the tenant server of a self-hosted install. In the cloud, the daily cap is the one limit you set, in the console, from 1 to 100,000, and it counts test sends too. The day is the UTC day. **Sent today** on the SMS tab stops at the cap: past it, sends are refused rather than sent. When sends reach the warning share of the cap (80% unless you change it), BuildBase emits `auth.sms_budget_exceeded` with `level: "warning"`, once that day. When the cap stops sends, it emits the same event with `level: "stopped"`, once that day, and the sign-in page says codes cannot be sent right now.

## The delivery log

The **Delivery log** on the SMS tab lists every send of the last 30 days, newest first: the last four digits of the number, the channel, what it was for, whether it was sent, refused or failed, and your provider's error code. The full number and the code are never kept. Rows older than 30 days are deleted.

Twilio's delivery status callbacks are not read, so the log records what your provider answered when the code was sent. A message Twilio accepted and then could not deliver shows as sent; Twilio's own message logs have the rest.

## What people see

- **Signing in.** The person picks their country, types their number and, if you offer more than one, chooses text message, WhatsApp or a voice call. A number with no account gets exactly the same answer as one with an account, so the page cannot be used to find out who has an account.
- **Each code** is 6 digits, lasts 10 minutes and allows 5 tries. A new code can be sent after 30 seconds, and each new code replaces the last.
- **Signing up.** A number with no account is asked for a name and an email after the code. The email starts unverified: a verification code goes to it, as after any sign-up, and the person confirms it on the next screen. Phone sign-up therefore needs a connected [email sender](/email/overview) as well.
- **An email that already has an account is never given the phone.** The answer says the address cannot be used here, and suggests signing in to that account and adding the phone there. Linking the number there would hand the account to whoever holds the phone. An address your [sign-up rules](/authentication/signup-access) do not let in gets those rules' own answer instead, as on any other sign-up form.
- **Closed sign-up.** With [public sign-up closed](/authentication/overview#closing-public-sign-up), a number with no account gets the answer a sent code gets, and no message is sent. Existing accounts still sign in by phone.
- **The second step.** If the org asks for one, a phone code is followed by it, as after any first step. A text message is never offered as the second step after a phone code.

A sign-in by phone code puts `sms-otp` in the user's activity, with the channel, and its token's `amr` claim says how: `["sms", "otp"]` for a text message, `["otp"]` for WhatsApp and `["tel", "otp"]` for a voice call.

### If you call the auth API yourself

The hosted page does this for you. The phone entry in `GET /api/v1/auth/supported-methods` carries the routes and the settings:

```json
{
  "id": "phone",
  "type": "phone",
  "name": "Phone",
  "validScreens": ["login", "register"],
  "url": "https://api.console.buildbase.app/api/v1/auth/phone",
  "formDetails": { "defaultCountry": "US", "channels": ["sms", "whatsapp"] }
}
```

It is there when the client has the Phone method and a provider is connected. Numbers are always sent as E.164. Errors are `{ success: false, message, reason }`; show `message` as it is.

`POST <url>/send` with `phone`, `channel` and, when bot protection is on, `turnstileToken`, answers `{ success: true, message, retryAfter: 30 }`.

| Refusal                    | When                                                                                                                                  |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `400` `invalid_phone`      | Not a valid phone number                                                                                                              |
| `429` `cooldown`           | A code went to this number in the last 30 seconds; `retryAfter` says when                                                             |
| `429` `rate_limited`       | Past the number's or the address's hourly or daily limit; `retryAfter` says how many seconds until every limit that refused has reset |
| `403` `bot_check_failed`   | The [bot protection](/authentication/bot-protection) check failed                                                                     |
| `403` `method_unavailable` | The client has no Phone method, or does not offer that channel                                                                        |
| `422` `number_refused`     | Your provider refused the number                                                                                                      |
| `503` `sms_unavailable`    | No provider, the provider failed, or the daily cap is reached                                                                         |

`POST <url>/verify` with `phone` and `code` answers a sign-in, `{ success: true, token }`, or the second step's envelope, or, for a number with no account, `{ success: true, signupRequired: true, signupToken }`. The sign-up token lasts 15 minutes.

| Refusal              | When                                                   |
| -------------------- | ------------------------------------------------------ |
| `400` `invalid_code` | The code is wrong                                      |
| `401` `code_expired` | No code is waiting, it lapsed, or its 5 tries are used |
| `403` `blocked`      | The account is blocked                                 |

`POST <url>/register` with `signupToken`, `name`, `email` and, when bot protection is on, `turnstileToken`, creates the account and answers like a sign-in.

| Refusal                   | When                                                                                                                   |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `409` `email_unavailable` | An account already has the address, or the account could not be created with it                                        |
| `403` `signup_restricted` | The address is on your blocklist, or missing from your allowlist. See [Who can sign up](/authentication/signup-access) |
| `403` `waitlist_required` | Waitlist mode, and the address has no approved entry                                                                   |
| `403` `signup_closed`     | Public sign-up is closed, or your plan's end-user limit is reached                                                     |
| `400` `disposable_email`  | A disposable address, when you refuse them                                                                             |
| `403` `bot_check_failed`  | The [bot protection](/authentication/bot-protection) check failed                                                      |
| `401` `signup_expired`    | The sign-up token is used or has lapsed, or the number has become an account since; start again with the number        |

## A phone on a signed-in account

A signed-in person can add a number to their account, prove it with a code, and remove it, from your app's account settings. The routes are under `/api/v1/public/profile`, authorized by the session in the `x-session-id` header, and the profile carries `phone` and `phoneVerified`.

| Route                                       | What it does                                                                                                                                                            |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /api/v1/public/profile/phone`         | Sends a code to the new number. `409` `phone_unavailable` for a number on another account, and the send refusals above.                                                 |
| `POST /api/v1/public/profile/phone/confirm` | Takes the `code` and verifies the number. `400` `invalid_code`, `410` `code_expired`.                                                                                   |
| `DELETE /api/v1/public/profile/phone`       | Removes it. `403` `reauth_required` for a session older than 10 minutes, `409` `last_sign_in_method`, `409` `mfa_sms_enrolled` while text messages are its second step. |

A verified number counts as a way in for [connected accounts](/authentication/connected-accounts) while your client offers phone sign-in.

## Text messages as a second step

People can also get their [two-step sign-in](/authentication/two-step) code by text message, if you allow it. It is the weaker choice: a phone number can be moved to another SIM by someone who talks a carrier into it, and a text can be intercepted. Offer it for people who would otherwise have no second step at all, not in place of the authenticator app.

- **Allow it** under **User Management → Access → Authentication → Security**, by ticking **Text message codes** in the two-step card. The list cannot be text messages alone; the authenticator app stays on beside it, and saving a list of text messages alone is refused with `sms_only_factor`.
- **It needs a connected provider** and a plan with phone sign-in codes. Without either, it is not offered.
- **People turn it on** from the Security screen of your app's settings, with a phone number they have verified.
- **At sign-in**, the hosted page offers "Text a code to" the masked number beside the authenticator app, as the weaker option, and sends nothing until asked. The code works once, and a new one can be sent after 30 seconds.
- **Never after a phone code.** A person who signed in with a code sent to their phone is not offered a text message as the second step.

## Events

| Event                      | When                                                                  | `data`                                   |
| -------------------------- | --------------------------------------------------------------------- | ---------------------------------------- |
| `user.phone_added`         | A number lands on an account: sign-up, the profile, or an import      | `actorId`, `userId`, `phone`, `verified` |
| `user.phone_verified`      | A number on an account is proven by a code                            | `actorId`, `userId`, `phone`             |
| `user.phone_removed`       | A number is removed from an account                                   | `actorId`, `userId`, `phone`             |
| `auth.sms_budget_exceeded` | Sends reach the warning share of the daily cap, or the cap stops them | `level`, `sent`, `dailyCap`, `alertAt`   |

`level` is `warning` or `stopped`, each at most once a day. Subscribe to any of them as a [webhook event](/webhooks/overview).

## Next steps

- [Bot Protection](/authentication/bot-protection) - put a Turnstile check in front of code sends.
- [Connected Accounts](/authentication/connected-accounts) - the ways a person can sign in, and removing one.
- [Importing Users](/authentication/importing-users) - bring numbers over with an import.
