Text messages and phone numbers
Signing in with a code sent by SMS, proving a phone number, and SMS as a second factor — sent from Base316's number or your own Twilio account.
A phone number can be how somebody signs in, how they sign up, and the second factor that protects an account. All three send a six-digit code by text message and wait for it to come back.
Who pays for the messages
Every text message costs money, and an endpoint that sends one on request is the target of SMS pumping: an attacker points it at premium-rate numbers they share revenue on, and whoever owns the account behind it pays. So whose account carries a message is a security rule, not a billing detail.
Text messages work out of the box: unless you connect your own Twilio account, they go out from Base316's number in both sandbox and production. Your own account always wins once it is connected.
| Base316's number | Your own Twilio account | |
|---|---|---|
| Countries | +1 (US and Canada) — it is a US toll-free number | Wherever Twilio reaches |
| Daily cap | 25 per sandbox, 1,000 per production environment | None — your send limits |
| Monthly allowance | Production only, from your plan (below); sandbox is not counted | Not counted |
| Sender | Base316's toll-free number; the message names your brand | Your number or service |
The daily caps bound what a pumping run can spend; a real launch should not meet them, and an environment that does belongs on its own account.
Allowance by plan
Production messages through Base316's number count against your workspace's monthly allowance, shown under Settings → Billing → Usage this month:
| Plan | Included each month | Past the allowance |
|---|---|---|
| Free | 100 | Refused until the month turns over |
| Pro | 1,000 | $0.02 per message, added to your next invoice |
| Scale | 10,000 | $0.02 per message, added to your next invoice |
| Enterprise | Per contract | Per contract |
On Free, a sign-in by text message past the allowance is refused, and the hosted page asks the user to sign in another way. Upgrade, or connect your own Twilio account, to keep sending.
Your own account has none of these fences: it is your balance, and the send limits below are yours to set.
Setting it up
Nothing is needed to use Base316's number. To send from your own Twilio account instead, open Settings → Text messages for the environment:
- Paste the Account SID (
AC…) and Auth token from the Twilio console. - Give a sender: a Twilio From number, or a Messaging service SID
(
MG…). A messaging service picks a sender per country, which is usually what you want if your users are international. - Press Save and verify. Base316 checks the credentials with Twilio before storing anything, so a wrong token is refused now rather than at somebody's first sign-in.
The auth token is encrypted at rest and only its last four characters are ever shown again. Send test sends a message through exactly the path a sign-in code takes, and the list below it shows what was sent recently — including why a message failed, in Twilio's words, which are never shown to your users.
Then turn on Sign in with a text message under Settings → Sign-in methods & design to add a "Text message" tab to the hosted sign-in page.
Base316 uses Twilio's Messaging API and keeps the codes itself — minted, hashed, expired and attempt-limited by the same rules as emailed codes. Twilio Verify would have split those rules across two systems.
Phone numbers
Numbers are stored in international (E.164) form: +14155550123. Spaces,
dashes, dots and brackets are accepted and dropped; a leading 00 becomes +.
A number in national form (0412 345 678) is refused, because turning it into
an international one needs to know which country it was typed in. Ask for the
country code.
Each number belongs to at most one user per environment. A user's number is either verified — a code sent to it came back — or not. Only a verified number signs anybody in.
Signing in with a code
On the hosted pages this is the "Text message" tab. From your own interface:
const { challengeToken, destinationHint } = await client.auth.phone.start({
phone: "+1 415 555 0123",
});
// destinationHint is "••••23": say where the code went without printing the number.
const result = await client.auth.phone.verify({ challengeToken, code });
if (needsSecondFactor(result)) {
// Exactly as after a password: answer with client.auth.verifyMfa.
}<SignIn phone /> in @base316/react adds the tab and handles both steps.
Every number gets the same answer. A number with no account, or whose account never verified it, gets a challenge and a masked number like any other — and a code box no code will satisfy. Nothing is actually texted to it. A wrong code, a spent one and a number with no account all fail with the same message, so the form cannot be used to find out who has registered.
A number joins an account only if that account verified it. A number you imported, or an operator typed in, is a claim; receiving a code at it proves the phone, not the account the number was typed into.
Signing up. Where the environment allows sign-up, a code to a number with no account creates one when the code comes back — a user with a verified number and no email address, in a tenant of their own like any other sign-up.
Codes are six digits, good for ten minutes, spent on first use, stored only as a digest, and retired after five wrong guesses. Asking again retires the code already sent.
Proving a number
For somebody already signed in:
const { challengeToken } = await client.auth.phone.startVerification(sessionToken, {
phone: "+14155550123",
});
await client.auth.phone.confirmVerification(sessionToken, { challengeToken, code });The number becomes the account's, verified, and user.phone_verified is
delivered to your webhooks. If another account had merely claimed the number
without verifying it, the claim moves to the person who proved it. If another
account has already verified it, confirming fails with a conflict and nothing
moves — that is for support to untangle.
You can also set phoneNumber on POST /v1/users or PATCH /v1/users/{id}. It
is stored unverified unless you also send phoneVerified: true, which is only
for migrating people whose number your previous system already verified by a
code.
As a second factor
const { factorId, destinationHint } = await client.auth.mfa.enrollSms(sessionToken, {
phone: "+14155550123", // omit to use the account's own number
});
await client.auth.mfa.confirm(sessionToken, { factorId, code });<TwoFactorManager allowSms /> offers it beside the authenticator app and the
emailed code. Where an environment requires a second factor, the hosted
enrolment page offers "Use a text message instead" whenever the environment can
send one.
A text message is the weakest of the three factors — SIM swaps are real — and the one people who will not install an app actually turn on. See Second factors.
Limits
Under Settings → Security & sign-up policy, in Text message limits:
- Messages per number — how many codes one number may be sent in the window (default 5 an hour). Stops somebody flooding a stranger's phone.
- Messages per address — how many one IP address may cause (default 20 an hour). Stops one client walking through a range of numbers.
Both count requests rather than messages actually sent, so they answer the same way for a number with an account and one without. Both follow the environment's attack protection mode and IP allow-list, like the sign-in limits. Wrong codes count toward the same per-account and per-address failure limits a wrong password does.
Personal data
A phone number is personal data. It is in the user's data export, alongside a list of the messages sent to it with the number masked to its last two digits. Erasing the user deletes the number and masks it in the message log, which is kept so an environment's message counts stay accountable.

