SMSTunnel for Better Auth
Phone number sign-in codes from your own phone
@narcisbodea/smstunnel-otp One function for sendOTP
Better Auth's phoneNumber plugin asks you how to send its codes.
smstunnelSendOTP() answers: it hands each code to your SMSTunnel account and your own
Android phone sends it from its own number, so people get it from a number they know.
Before you start
- 1. Install the SMSTunnel app from Google Play on your Android phone and sign in.
- 2. In your dashboard, open Apps → Add an app, pick Sign-in codes, name it (for example "Better Auth") and click Create the key.
- 3. Copy the key right away: it's shown only once. You can stop or delete the app there anytime, and Better Auth can't send texts anymore.
Get the steps with your key in them
It opens Add an app with Sign-in codes picked. Once the key is made, the dashboard shows every step and command with the key already filled in, ready to copy.
Keep the key in your platform's secrets, never in code that runs in the browser. Codes must be 4 to 10 digits: the default 6-digit codes are fine; codes with letters aren't accepted.
1. Install
npm install @narcisbodea/smstunnel-otp No runtime dependencies: it runs in Node 18+, Deno, Bun and Cloudflare Workers.
2. Plug it into the phoneNumber plugin
import { betterAuth } from 'better-auth';
import { phoneNumber } from 'better-auth/plugins';
import { smstunnelSendOTP } from '@narcisbodea/smstunnel-otp';
const apiKey = process.env.SMSTUNNEL_API_KEY!;
export const auth = betterAuth({
plugins: [
phoneNumber({
sendOTP: smstunnelSendOTP({ apiKey, template: 'Your Acme code is {code}' }),
sendPasswordResetOTP: smstunnelSendOTP({ apiKey, template: 'Your Acme password reset code is {code}' }),
}),
],
}); -
Better Auth runs
sendOTPin the background when it can, so sign-in doesn't wait for the send. -
If people type local numbers, add
defaultCountryCodeto thesmstunnelSendOTP()options (for example'44'), or validate numbers with the plugin'sphoneNumberValidator. -
Other options:
deviceId(send from one phone of your account),serverUrl(self-hosted SMSTunnel) andtimeoutMs(default 10000). - If you change the code length, keep it between 4 and 10 digits.
When a code can't be sent
The function rejects with a SmsTunnelOtpError:
| Field | Meaning |
|---|---|
| status | HTTP status from SMSTunnel; 0 for a timeout, a network error or a refused argument |
| code | SMSTunnel's own code when it sends one (for example PERMISSION_REQUIRED), else UNAUTHORIZED (401), FORBIDDEN (403), BAD_REQUEST (400), RATE_LIMITED (429), SERVER_ERROR (5xx), TIMEOUT, NETWORK_ERROR, INVALID_ARGUMENT, MESSAGE_TOO_LONG |
| retryable | true for 429, 5xx, timeouts and network errors |
| message | What went wrong, in words |
Worth knowing
- Codes have their own allowance. They go through SMSTunnel's code endpoint,
POST /api/v1/sms/send-2fa, which counts them apart from your other texts. See Pricing. - What counts as a code: 4 to 10 digits standing on their own, like
482913, or two groups of 3 digits, like123-456. A text without one is refused. Details - One number per text, up to 320 characters. The package refuses a longer text before calling SMSTunnel.
- 30 sends a minute. Past that, SMSTunnel answers 429 and asks to retry a little later.
- Phone offline? The code waits and goes out as soon as the phone is back.
- Nothing in the package logs codes or keys.
Build it with an AI assistant
Copy this prompt into Claude, ChatGPT, Cursor or Copilot and replace the part in [brackets]. Don't paste your API key into the chat: the prompt doesn't need it.
Help me send the phone number sign-in codes of my Better Auth app from my own phone, with SMSTunnel.
About SMSTunnel (https://smstunnel.io): my own apps and tools hand text messages (SMS) to my SMSTunnel account, and my own Android phone sends them from my own number, on my own SIM and plan. The phone runs the SMSTunnel app from Google Play (https://play.google.com/store/apps/details?id=io.smstunnel.app), signed in to my account.
What I want: [describe what should send a text, to whom, and when]
How it works:
- Install: npm install @narcisbodea/smstunnel-otp (no dependencies; Node 18+, Deno, Bun, Cloudflare Workers).
- Better Auth's phoneNumber plugin takes sendOTP and sendPasswordResetOTP functions; smstunnelSendOTP() builds them:
import { betterAuth } from 'better-auth';
import { phoneNumber } from 'better-auth/plugins';
import { smstunnelSendOTP } from '@narcisbodea/smstunnel-otp';
const apiKey = process.env.SMSTUNNEL_API_KEY!;
export const auth = betterAuth({
plugins: [
phoneNumber({
sendOTP: smstunnelSendOTP({ apiKey, template: 'Your Acme code is {code}' }),
sendPasswordResetOTP: smstunnelSendOTP({ apiKey, template: 'Your Acme password reset code is {code}' }),
}),
],
});
- smstunnelSendOTP options: apiKey (required), template (text with {code}; default "Your verification code is {code}"), defaultCountryCode (for numbers typed without + or 00, for example '44'), deviceId (send from one phone of my account), serverUrl (self-hosted SMSTunnel), timeoutMs (default 10000).
- It rejects with SmsTunnelOtpError when the code can't be sent: fields status, code (e.g. PERMISSION_REQUIRED, UNAUTHORIZED, RATE_LIMITED, TIMEOUT, MESSAGE_TOO_LONG), retryable (true for 429, 5xx, timeouts, network errors) and message.
- Better Auth runs sendOTP in the background when it can, so sign-in doesn't wait for the send. Numbers can also be checked with the plugin's phoneNumberValidator.
- Codes go through SMSTunnel's POST https://smstunnel.io/api/v1/sms/send-2fa: the text must contain the code as 4 to 10 digits (keep the code length in that range) and can be up to 320 characters. Codes have their own daily allowance. If the phone is offline, the code waits and goes out when it is back.
- The API key comes from my SMSTunnel dashboard (https://dashboard.smstunnel.io): Apps → Add an app → Create the key. It is shown only once and can send texts and check whether they went out.
Rules for your answer:
- Keep the API key in an environment variable or the platform's secret store (for example SMSTUNNEL_API_KEY). Never put it in source code, in a repository, or in code that runs in a browser or a mobile app: call SMSTunnel from a server. I will not paste my key in this chat; use a placeholder.
- Validate phone numbers before sending (international format, like +14155550123) and skip or report the invalid ones.
- Handle errors: log or show what went wrong, retry only after 429, 5xx or network errors and only a few times, and never send the same text twice.
- Only send texts people expect from me: alerts to myself, reminders, sign-in codes, updates they asked for. No unsolicited texts. Better Auth is a trademark of its owner. SMSTunnel is a third-party integration and is not affiliated with, endorsed by, or sponsored by Better Auth.
Sign-in codes for other platforms
Another platform? sendCode() from the same package
sends any code: see its README.