Phone one-time codes
A phone number gets a six-digit code and trades it for the same session any other auth flow issues. The number is normalised to E.164 before anything else happens, only a digest of the code is stored, and every send is capped — because unlike an email, each message costs you money.
The two requests
curl -s -X POST "$URL/auth/v1/otp" \
-H 'content-type: application/json' \
-d '{"phone":"+14155552671"}'
{"message":"If that number can receive messages, a code is on its way."}
The same answer for a first send, a resend inside the cooldown, a banned account and a number nobody has ever used. The account is created on the first request, so there is nothing to reveal about who is registered.
curl -s -X POST "$URL/auth/v1/verify" \
-H 'content-type: application/json' \
-d '{"phone":"+14155552671","token":"418302"}'
{"user":{"id":"b21e…","phone":"+14155552671","email":null,
"phone_confirmed_at":"2026-08-24T11:02:44.881Z","confirmed_at":"2026-08-24T11:02:44.881Z",
"app_metadata":{"provider":"phone","providers":["phone"]},"user_metadata":{}, "…":"…"},
"session":{"access_token":"eyJ…","token_type":"bearer","expires_in":3600,
"expires_at":1772002964,"refresh_token":"pQ9…","user":{"…":"…"}}}
Redeeming the code is what proves the handset is theirs, which is exactly what
phone_confirmed_at records. The session is identical to a password session in
every other way — same claims, same rotation, same policies.
// The SDK's signInWithOtp is email-only; a phone code is two plain calls.
await fetch(BASELYRA_URL + '/auth/v1/otp', {
method: 'POST',
headers: { 'content-type': 'application/json', apikey: ANON_KEY },
body: JSON.stringify({ phone }),
});
const res = await fetch(BASELYRA_URL + '/auth/v1/verify', {
method: 'POST',
headers: { 'content-type': 'application/json', apikey: ANON_KEY },
body: JSON.stringify({ phone, token: code }),
});
const { session } = await res.json();
bl.auth.setSession(session);
Numbers are normalised first
Every number is converted to E.164 — a plus, a country code that never starts
with zero, at most fifteen digits — before it reaches the database or causes a
message. Only the normalised form is ever stored, because
+1 415 555 2671 and (415) 555-2671 are one handset and would otherwise be two
accounts.
| Typed | With SMS_DEFAULT_COUNTRY=1 | Why |
|---|---|---|
+14155552671 | +14155552671 | Already international; untouched |
(415) 555-2671 | +14155552671 | The default country code is prefixed |
0415 555 2671 | +14155552671 | One leading trunk zero is dropped |
001 415 555 2671 | +14155552671 | 00 is the international access prefix |
+1 415 555 2671 ext 4 | rejected | Silently turning it into a number ending in 4 would text a stranger |
+44 20 +7946 0018 | rejected | A plus after the first character is two numbers, not a formatting quirk |
4155552671 with no default country | rejected | There is nothing to resolve it against |
{"error":{"code":"bad_request",
"message":"A valid phone number in international format is required","details":null}}
A CHECK constraint on auth.users.phone enforces the same shape at the
database level, so a hand-written UPDATE or an admin edit cannot reintroduce a
badly formatted number. It is added NOT VALID so the migration cannot fail on
rows an earlier build wrote; promote it once you have cleaned those up:
alter table auth.users validate constraint users_phone_e164;
Choosing a sender
Set SMS_PROVIDER to one of these and give it the credentials it needs.
SMS_PROVIDER | Needs |
|---|---|
twilio | TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, SMS_FROM |
vonage | VONAGE_API_KEY, VONAGE_API_SECRET, SMS_FROM |
messagebird | MESSAGEBIRD_ACCESS_KEY, SMS_FROM |
sns | AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, optionally AWS_SESSION_TOKEN |
plivo | PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN, SMS_FROM |
webhook | SMS_WEBHOOK_URL, optionally SMS_WEBHOOK_SECRET |
SMS_PROVIDER=twilio
TWILIO_ACCOUNT_SID=AC…
TWILIO_AUTH_TOKEN=…
SMS_FROM=+15005550006
SMS_DEFAULT_COUNTRY=1
An unknown id is refused at send time with the list of the ones that exist, and the Studio's readiness check reports a provider missing a credential by name before you trust the flow:
{"ok":false,"error":"twilio needs TWILIO_AUTH_TOKEN, SMS_FROM to be set"}
With no provider configured
With SMS_PROVIDER unset the code is printed to the log instead of sent, exactly
as email is, so you can finish a sign-in on a laptop:
[sms] no SMS provider configured, would have sent to +14155552671
[sms] | 418302 is your verification code. It expires in 10 minutes.
That happens only when SMS_PROVIDER is unset. A configured provider that
refuses a message throws.
The webhook sender
For a carrier Baselyra does not speak, or an internal gateway. Baselyra
POSTs JSON to SMS_WEBHOOK_URL; you deliver it however you like.
SMS_PROVIDER=webhook
SMS_WEBHOOK_URL=https://sms.internal.example.com/send
SMS_WEBHOOK_SECRET=…
The limits that protect your balance
| Limit | Default | Variable |
|---|---|---|
Per IP, per minute, on /otp | 5 | fixed |
| Cooldown before the same number gets another code | 60 s | SMS_OTP_COOLDOWN |
| Hard cap per number per hour | 5 | SMS_MAX_PER_NUMBER_PER_HOUR |
| Failed verifications per number+IP | AUTH_MAX_ATTEMPTS (8) per AUTH_ATTEMPT_WINDOW (900 s) |
Inside the cooldown the caller gets the same answer as a real send, minus the message: a resend that failed loudly would tell an attacker the first one arrived. Past the hourly cap the answer changes, because there is nothing to protect any more — the number is already known to be in use by whoever is pressing the button:
{"error":{"code":"rate_limited",
"message":"Too many codes have been sent to this number. Try again later.","details":null}}
Sends are counted in auth.attempts under an sms:-prefixed identifier, so
they share the table and its sweeper with failed sign-ins without spending a user's
failure budget on messages they successfully received. The counter is incremented
before the send, because a message the provider accepted and then failed to
deliver has still been paid for.
How the code itself is handled
- Six digits from
crypto.randomInt— uniform and CSPRNG-backed, because this string is a credential. - Only a SHA-256 digest is stored, in
auth.one_time_tokens, hashed with the number as its scope. Without that scope, six digits could be matched against every pending code in the table at once instead of against one account's. - Issuing a new code retires the outstanding one, so the oldest code anybody received does not stay live for its full lifetime.
- Comparison is
timingSafeEqualover the digests. Twenty bits of entropy is little enough that an early-returning===would leak a usable prefix. - Redemption is single-use by construction: the
UPDATEre-checksused_at IS NULLunder the row lock, so of two concurrent redemptions exactly one gets the row. - A failed verification is recorded against the number, and a successful one clears the counter.
{"error":{"code":"unauthorized","message":"This code is invalid, expired or already used","details":null}}
One message for a wrong code, an expired code, a spent code and a number with no outstanding code at all.
Phone users in policies
A phone-only user has email = null, so auth.email() is NULL for them.
A policy written as using (owner_email = auth.email()) silently admits nothing
for every phone user on the instance. Key on auth.uid().
-- fine for everyone
using (user_id = (select auth.uid()))
-- silently empty for phone-only users
using (owner_email = auth.email())
The phone number is also in the claims, so it is readable in a policy if you need it:
using (phone = (auth.jwt() ->> 'phone'))
Failure modes
| What you see | Why | Fix |
|---|---|---|
400 — A valid phone number in international format is required | The number would not normalise | Send E.164, or set SMS_DEFAULT_COUNTRY |
200 and no message | Inside the 60-second cooldown, or no provider configured | Wait, or check the log for the printed code |
429 rate_limited | Past SMS_MAX_PER_NUMBER_PER_HOUR | Wait an hour, or raise it knowingly |
| Codes stop arriving for one number only | The hourly cap is per number | It resets on a rolling hour |
Unknown SMS_PROVIDER "twillio" | A typo | One of twilio, vonage, messagebird, sns, plivo, webhook |
| A user has two accounts for one phone | Rows written before the E.164 constraint existed | Normalise them, merge, then validate constraint users_phone_e164 |
200 and no code, for a number that never gets one | AUTH_ALLOW_SIGNUPS=false and that number has no account. The endpoint still answers identically rather than revealing which numbers exist. | Create the user with the service key first, or allow signups |