Skip to content

Send one-time codes by SMS

Send one-time codes by SMS

The package ships an SMS channel, Otp\Channels\SmsOtpChannel, and four provider drivers that talk HTTP through Laravel's client — no provider SDK is a dependency. Every text passes a toll-fraud guard first and is audited with the number masked.

To use SMS as a second factor (enrol a number, challenge at sign-in), see Offer SMS as a second factor instead — it uses its own channel so the number is stored sealed, never in the OTP tables.

1. Pick a provider

.env:

CBOX_ID_SMS_DRIVER=twilio          # twilio | messagebird | bird | 46elks | log | array
CBOX_ID_SMS_ALLOWED_COUNTRIES=DK,SE,NO,FI   # set it: see "Toll fraud" below

# Twilio — a Messaging Service is preferred over a single From number
TWILIO_ACCOUNT_SID=AC…
TWILIO_AUTH_TOKEN=…
TWILIO_MESSAGING_SERVICE_SID=MG…   # or TWILIO_FROM=+45…
Driver API Settings (cbox-id.sms.drivers.*)
twilio Programmable Messaging, POST /2010-04-01/Accounts/{sid}/Messages.json TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN (or TWILIO_API_KEY + TWILIO_API_SECRET), TWILIO_MESSAGING_SERVICE_SID or TWILIO_FROM
messagebird MessageBird REST, POST /messages MESSAGEBIRD_ACCESS_KEY, MESSAGEBIRD_ORIGINATOR
bird Bird Channels API, POST /workspaces/{ws}/channels/{ch}/messages BIRD_ACCESS_KEY, BIRD_WORKSPACE_ID, BIRD_CHANNEL_ID
46elks POST /a1/sms ELKS_API_USERNAME, ELKS_API_PASSWORD, ELKS_FROM, ELKS_DRY_RUN
log none — writes the message, code included, to the log refused when APP_ENV=production
array none — keeps messages in memory tests

Every driver also takes a *_BASE_URL (a regional edge, a local double). Credentials are read at the first send, so a deployment that never texts never needs them. Senders do not retry: a retry after a timeout can deliver and bill twice, and the person waiting can press "resend", which goes back through the guard.

Your own gateway: implement Otp\Sms\Contracts\SmsSender and put its class name in CBOX_ID_SMS_DRIVER. It inherits every control below without implementing any.

2. Map the channel and issue

// config/cbox-id.php
'otp' => ['channels' => [
    'email' => \Cbox\Id\Otp\Channels\EmailOtpChannel::class,
    'sms'   => \Cbox\Id\Otp\Channels\SmsOtpChannel::class,
]],
$challenge = app(OtpService::class)->issue('login', '+45 12 34 56 78', 'sms', $request->ip());

The recipient may be formatted (+45 12 34 56 78, 0045…, +44 (0)20…); it is normalised to E.164 by Otp\Sms\PhoneNumberNormaliser, which also assigns the country. A number it cannot place in a country is refused (InvalidPhoneNumber). Note that the OTP module stores the recipient — here the phone number — in otp_challenges.recipient, as it does an email address.

The text is written in the request's locale (en, da, de, fr, nb, sv ship; others fall back to English), code first, inside one GSM-7 segment. Override any language under cbox-id.sms.messages with :code, :app and :minutes.

3. Toll fraud

SMS pumping: an attacker drives a public "text me a code" form at premium-rate number ranges and shares the revenue with whoever terminates them. Every send passes Otp\Sms\Contracts\SmsSendGuard (RateLimitedSmsSendGuard) before the provider is called, and nothing is counted when it refuses:

Control Setting Default
Country allow-list (deployment) CBOX_ID_SMS_ALLOWED_COUNTRIES empty = no deployment restriction
Non-geographic ranges (+881, +882, +883, +979, …) — always refused
One text per number per … CBOX_ID_SMS_COOLDOWN_SECONDS 30
Texts per number per day (per environment) CBOX_ID_SMS_PER_NUMBER_PER_DAY 10
Texts per requesting IP per hour (per environment) CBOX_ID_SMS_PER_IP_PER_HOUR 10
Texts per environment per day CBOX_ID_SMS_PER_ENVIRONMENT_PER_DAY 1000
Texts per deployment per day — the circuit breaker CBOX_ID_SMS_DAILY_CAP 5000

On top of these, the OTP module's own issue caps apply (cbox-id.otp.issue.*).

A refusal throws Otp\Sms\Exceptions\SmsSendRefused with a reason (SmsRefusalReason) and retryAfterSeconds. Every send is audited: sms.sent (provider, message id), sms.refused (reason), sms.failed — each with the number masked (+45 ******78), its country, the purpose and the IP. Pumping shows up as a pattern on those rows without the rows holding the numbers.

The counters live in the cache store: it must be shared between replicas, or each replica has its own budget. Also set spend limits and geo-permissions at the provider; they are the last line, and they do not depend on this package being configured right.

Testing

use Cbox\Id\Otp\Sms\Testing\InteractsWithSms;

$sms = $this->fakeSms();                // binds an ArraySmsSender
// … drive the flow …
$sms->assertSent('+4512345678');
$code = $sms->latestCode();

For the driver itself, Http::fake() the provider host — the drivers use Laravel's HTTP client.

Honest limits

  • SMS is the weakest second factor: SIM swap, number port-out, SS7 interception and real-time phishing relays all take the code. See Security: OTP.
  • The normaliser knows calling codes, the North American area codes that are not the US, and lengths for common European plans. It does not know which ranges are allocated; the provider rejects those.