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.
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
- 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.
- Set the daily cap and the share of it at which you are warned. See Limits.
- 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 keeps the reason. A test that never reaches a provider says so instead:
429 daily_cap_reachedonce the day's cap is used up, and409 sms_unavailablewhile no provider is connected. - 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.
- 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.
{
"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 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:
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:
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 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 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, 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:
{
"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 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 |
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 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 while your client offers phone sign-in.
Text messages as a second step
People can also get their two-step sign-in 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.
Next steps
- Bot Protection - put a Turnstile check in front of code sends.
- Connected Accounts - the ways a person can sign in, and removing one.
- Importing Users - bring numbers over with an import.