Baselyra Docs

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.

TypedWith SMS_DEFAULT_COUNTRY=1Why
+14155552671+14155552671Already international; untouched
(415) 555-2671+14155552671The default country code is prefixed
0415 555 2671+14155552671One leading trunk zero is dropped
001 415 555 2671+1415555267100 is the international access prefix
+1 415 555 2671 ext 4rejectedSilently turning it into a number ending in 4 would text a stranger
+44 20 +7946 0018rejectedA plus after the first character is two numbers, not a formatting quirk
4155552671 with no default countryrejectedThere 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_PROVIDERNeeds
twilioTWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, SMS_FROM
vonageVONAGE_API_KEY, VONAGE_API_SECRET, SMS_FROM
messagebirdMESSAGEBIRD_ACCESS_KEY, SMS_FROM
snsAWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, optionally AWS_SESSION_TOKEN
plivoPLIVO_AUTH_ID, PLIVO_AUTH_TOKEN, SMS_FROM
webhookSMS_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

LimitDefaultVariable
Per IP, per minute, on /otp5fixed
Cooldown before the same number gets another code60 sSMS_OTP_COOLDOWN
Hard cap per number per hour5SMS_MAX_PER_NUMBER_PER_HOUR
Failed verifications per number+IPAUTH_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 timingSafeEqual over 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 UPDATE re-checks used_at IS NULL under 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 seeWhyFix
400A valid phone number in international format is requiredThe number would not normaliseSend E.164, or set SMS_DEFAULT_COUNTRY
200 and no messageInside the 60-second cooldown, or no provider configuredWait, or check the log for the printed code
429 rate_limitedPast SMS_MAX_PER_NUMBER_PER_HOURWait an hour, or raise it knowingly
Codes stop arriving for one number onlyThe hourly cap is per numberIt resets on a rolling hour
Unknown SMS_PROVIDER "twillio"A typoOne of twilio, vonage, messagebird, sns, plivo, webhook
A user has two accounts for one phoneRows written before the E.164 constraint existedNormalise them, merge, then validate constraint users_phone_e164
200 and no code, for a number that never gets oneAUTH_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

Edit this page Report a problem

Esc
navigate open Esc close