Supabase Auth supports four SMS providers: MessageBird, Twilio, Vonage, and TextLocal. There is no credential shape, no base URL override, and no request template for declaring a fifth. If you need MSG91 in India or Termii in Nigeria, the dashboard has nowhere to put them.
The fix is the Send SMS hook and it takes about forty lines. Split the OTP flow at its trust boundary, hand only delivery to an endpoint you control, and leave every security-relevant operation with GoTrue. The four things that decide whether your implementation is sound, in the order they bite:
- Verify the signature against the raw request body, not a re-serialised one. This is the defect that produces a uniform 401 you will waste a day on.
- Bound the timestamp or your signature check authenticates origin but not freshness.
- Bound the transport call with an explicit timeout, and read the response body rather than the status line alone.
- Write down that your endpoint is now a single point of failure for all authentication. There is no fallback after delegation. That is the real cost and the rest of this post builds to it.
The seam you are cutting along
A phone OTP cycle is two client transactions separated by an out-of-band delivery.
Issuance. The client submits a phone number in E.164. GoTrue generates a code, hashes and stores it against the number, stamps an expiry from sms_otp_exp, and enforces two throttles: a per-number interval from sms_max_frequency and a project-wide rate ceiling. Only then does delivery happen.
Verification. The client submits the number and a candidate code. GoTrue recomputes the hash, compares, checks expiry, evaluates an attempt counter, and mints a session.
Between them sits one operation that is not security-bearing: moving six digits across a telecom network. It has no custody of the hash, no authority over expiry, no part in rate limiting, no role in session issuance. That is the seam, and the hook exists to cut along it.
Whether you need to cut it is usually decided by regulation or economics rather than preference. The binding case is India, where TRAI requires commercial traffic to resolve to a registered entity, sender header, and pre-approved message template, so an aggregator without standing in that regime cannot deliver at all. Elsewhere it is in-country interconnect pricing, which is load-bearing on unit economics when every sign-in costs a message, or tail latency that eats a five-minute expiry window and measurably lowers verification completion. If none of those bind you, use one of the four and stop reading.
What stays with GoTrue
| Responsibility | Owner after delegation |
|---|---|
| Code generation and entropy | GoTrue |
| Hashing and storage at rest | GoTrue |
Expiry, set by sms_otp_exp | GoTrue |
Per-number throttle, set by sms_max_frequency | GoTrue |
| Project-wide rate ceiling | GoTrue |
| Verification attempt limiting | GoTrue |
| Session minting, refresh rotation, revocation | GoTrue |
| Getting the code onto a handset | Your endpoint |
Every row but the last is a security control with a documented failure literature. Hand-rolled OTP implementations show up in incident reports with a consistent signature: codes stored in cleartext, no attempt limiting (which reduces a six-digit code to a trivially enumerable space), tokens without bounded lifetime, and debug bypasses that survive into production. Keeping those rows with GoTrue is the whole argument for the hook over writing your own.
sequenceDiagram
autonumber
participant C as Client
participant S as GoTrue
participant H as Hook endpoint
participant P as SMS provider
C->>S: signInWithOtp(phone)
S->>S: Generate code, hash it, store it, stamp expiry
S->>S: Check per-number throttle and rate ceiling
S->>H: POST /send-sms, Standard Webhooks signed<br/>{ user.phone, sms.otp }
H->>H: Verify signature over raw request body
alt Signature valid
H->>P: Submit message
alt Provider accepted
P-->>H: Accepted
H-->>S: 200 {}
S-->>C: Issuance acknowledged
else Provider rejected
P-->>H: Error
H-->>S: 502 { error }
S-->>C: Failure surfaced to caller
end
else Signature invalid
H-->>S: 401 { error }
end
C->>S: verifyOtp(phone, candidate)
S->>S: Recompute hash, compare, check expiry and attempts
S-->>C: Session: access token plus refresh token
The property to internalise: hashing happens before the hook is called, and verification succeeds or fails without any participation from your endpoint. Your endpoint sees the code once, in transit, and holds no authority over whether it is accepted. That is what makes the delegation safe.
Payload. Two fields are load-bearing: user.phone in E.164, and sms.otp. The rest of the user record is available for routing by country code but nothing required depends on it.
{
"user": { "id": "6481a5c1-3d37-4a56-9f6a-bee08c554965", "phone": "+1333363128" },
"sms": { "otp": "561166" }
}
Response. {} with 200 means dispatched. Failure goes back as { "error": { "http_code": 502, "message": "sms delivery failed" } }, which GoTrue propagates to the caller. Returning 200 on a dispatch that failed is a correctness bug with a terrible signature: GoTrue records success, the client gets an acknowledgement, and the user waits on a message that will never arrive with no error anywhere in the system.
The four ways people get the signature wrong
Your endpoint is an unauthenticated HTTPS resource, reachable from the internet, whose invocation dispatches messages billed to your account, and it receives live codes. Without origin authentication anyone who finds the URL can send to attacker-chosen destinations at your expense and against your registered sender identity. Without replay protection one captured request stays valid forever. Standard Webhooks addresses both, if you implement it correctly. Here is how people fail to.
Hashing a re-serialised body. The MAC covers the exact bytes transmitted. A framework that parses JSON and application code that re-serialises it will generally produce different bytes: key order is not guaranteed, whitespace is dropped, escaping is normalised. The hash never matches. Capture the raw body, verify against it, parse only afterwards. Frameworks that eagerly parse need explicit config to retain the raw buffer.
Skipping the timestamp bound. Signature verification without a replay window authenticates origin but not freshness. Five minutes is a defensible tolerance: wide enough for clock skew, narrow enough to bound replay.
Parsing one signature instead of the list. The header carries a space-delimited list, deliberately, so a secret rotation window can present both the old and new signature. Treat it as a scalar and you verify correctly in steady state and fail at the exact moment a rotation is in progress, which is a total authentication outage at the worst time.
Comparing with ===. String and buffer equality short-circuit on first difference and leak the correct hash through timing. Use a constant-time comparison behind a length check, since those primitives throw on length mismatch.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyStandardWebhook(
secret: string,
headers: { id?: string; timestamp?: string; signature?: string },
rawBody: string,
nowSeconds = Math.floor(Date.now() / 1000),
): boolean {
const { id, timestamp, signature } = headers;
if (!id || !timestamp || !signature) return false;
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(nowSeconds - ts) > TOLERANCE_SECONDS) {
return false;
}
// Strip the scheme version and secret prefixes, then decode. The HMAC key
// is the decoded bytes, not the ASCII string.
const key = Buffer.from(
secret.replace(/^v1,/, "").replace(/^whsec_/, ""),
"base64",
);
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest();
return signature.split(" ").some((entry) => {
const [version, value] = entry.split(",");
if (version !== "v1" || !value) return false;
const given = Buffer.from(value, "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
The endpoint
const err = (http_code: number, message: string) =>
Response.json({ error: { http_code, message } }, { status: http_code });
export async function sendSmsHook(req: Request): Promise<Response> {
const secret = process.env.SEND_SMS_HOOK_SECRET;
if (!secret) return err(500, "hook secret not configured");
const rawBody = await req.text(); // raw bytes, before any parsing
const authentic = verifyStandardWebhook(secret, {
id: req.headers.get("webhook-id") ?? undefined,
timestamp: req.headers.get("webhook-timestamp") ?? undefined,
signature: req.headers.get("webhook-signature") ?? undefined,
}, rawBody);
if (!authentic) return err(401, "invalid signature");
const event = JSON.parse(rawBody);
const phone = event.user?.phone;
const otp = event.sms?.otp;
if (!phone || !otp) return err(400, "phone and otp required");
try {
await sendSms(phone, `Your verification code is ${otp}`);
return Response.json({});
} catch (e) {
console.error("[sendSmsHook]", e); // never log the code
return err(502, "sms delivery failed");
}
}
Stateless by design: nothing persisted, no session, no database, so there is no durable state to corrupt and recovery is a redeploy.
The code must not reach any log sink, error aggregator, trace, or request archive. For its expiry window it is a live credential. Structured logging that serialises whole request objects is the usual vector, and it usually arrives long after the endpoint is written, through a change to shared middleware rather than to the handler.
Provider adapters
sendSms is the only part that varies. Two shapes cover most providers.
Bearer-authenticated JSON, the prevailing modern shape:
export async function sendSms(recipient: string, message: string): Promise<void> {
const response = await fetch("https://api.example-provider.com/v1/sms/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.SMS_API_TOKEN}`,
},
body: JSON.stringify({
recipient, // E.164, forwarded unmodified
sender_id: process.env.SMS_SENDER_ID, // provisioned header
message,
}),
// Unbounded calls block the hook, which blocks issuance, which looks to
// the user like a frozen app.
signal: AbortSignal.timeout(10_000),
});
const json: any = await response.json().catch(() => null);
// Many SMS APIs return 200 with a failure flag in the body. Check both.
if (!response.ok || json?.status !== "success") {
throw new Error(`provider rejected: http ${response.status}`);
}
}
Template-referenced, where regulation mandates pre-registered templates, so the request carries a template id and a variable binding instead of a message body:
export async function sendSms(recipient: string, otp: string): Promise<void> {
const response = await fetch("https://api.example-provider.com/v5/flow", {
method: "POST",
headers: { "Content-Type": "application/json", authkey: process.env.SMS_AUTH_KEY! },
body: JSON.stringify({
template_id: process.env.SMS_TEMPLATE_ID,
recipients: [{ mobiles: recipient.replace("+", ""), otp }],
}),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`provider rejected: http ${response.status}`);
}
Note the signature difference: this one takes the code as a discrete variable, because the surrounding copy is fixed at registration. If you must support both regimes, the adapter interface takes { recipient, otp, purpose } and lets each implementation decide whether it interpolates or binds. An interface accepting only a rendered string cannot express the template case.
Two rules hold for every adapter. Forward the number in the form GoTrue gave you and normalise inside the adapter, because normalising upstream couples the handler to one provider’s quirk. And assume a 200 can carry an in-body failure until you have verified otherwise for that vendor.
Configuration
Dashboard: Authentication, Hooks, Send SMS hook, as an HTTPS endpoint. The secret is shown once and goes into the endpoint environment as SEND_SMS_HOOK_SECRET. For anything past a prototype prefer the Management API, which is reproducible and reviewable next to your migrations:
curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"external_phone_enabled": true,
"sms_otp_exp": 300,
"sms_max_frequency": 30,
"hook_send_sms_enabled": true,
"hook_send_sms_uri": "https://your-service.example.com/api/auth/send-sms",
"hook_send_sms_secrets": "v1,whsec_..."
}'
sms_template is read only by native providers, not by the hook, whose message body your adapter composes. Config changes are not immediately visible to the running service; we have seen one to two minutes of propagation, during which the old config still applies. Validate after a delay before concluding a change failed.
What it costs you
Two consequences generate the incidents.
The native provider config goes inert. Supabase says so in the dashboard: provider settings are disabled while the hook is enabled, and credentials for whichever provider is still selected are no longer read. Engineers lose hours debugging credentials that are not on any execution path. Note the distinction: phone auth itself must stay enabled, since that flag controls whether phone login exists at all. Only the provider selection beneath it goes inert.
You lose your delivery fallback. This is the substantive trade and it belongs in the design review that approves the change. Before delegation, delivery availability is Supabase’s problem, discharged against their vendor relationships. After, your endpoint is a hard dependency of sign-in with no configured alternative. If it errors, times out, or fails to resolve, signInWithOtp fails and nobody can establish a session. The blast radius is total, and the failure is not degraded, it is complete.
It is also poorly observable from where you normally look. Application dashboards show reduced traffic rather than errors, because the failing requests never reach your app. The signal lives on the hook endpoint alone. So: host it at the availability tier of your database rather than of an ancillary service, bound the provider call and allow at most one retry, and alert on that route’s error rate with a page rather than an email. Where auth availability is revenue-critical, put a second provider behind the same adapter interface and fail over on health check. One interface, two implementations.
Testing without burning messages
Unit test the verifier, not the provider. The adapter fails loudly in staging; the verifier fails only against a correctly signed request, which a naive test never constructs. Three assertions cover it: sign a request with a known secret and assert it verifies, mutate one byte of the body and assert it fails, backdate the timestamp past tolerance and assert it fails. Correctness, integrity, freshness.
For end-to-end, register specific phone numbers against fixed codes. For a registered number the service short-circuits before delivery: the hook is never called, nothing is dispatched, nothing is billed. That is also what lets an App Store or Play reviewer who cannot receive SMS in your market authenticate against a submitted build, which has its own failure modes covered in how to let an App Store reviewer log into a phone-OTP app.
Record the number, the provider’s response flag, latency, and outcome. Do not record the code. Where shared middleware serialises bodies, add an explicit redaction rule for sms.otp rather than trusting the handler.
Failure catalogue
| Symptom | Probable cause |
|---|---|
| Uniform 401 under all conditions | Hash computed over a re-serialised body, or the v1, and whsec_ prefixes were not both stripped before base64 decoding |
| Verifies locally, 401 in deployment | A proxy or middleware mutates the body before capture. Retain the raw buffer at the edge |
| Verification breaks right after a secret rotation | Signature header parsed as a scalar instead of a space-delimited list |
| Client reports an unsupported phone provider | The hook is enabled while phone auth itself is disabled. Distinct settings |
| Sign-in hangs, then times out | No timeout on the provider call |
| No message, no error anywhere | Provider returned 200 with an in-body failure flag and the adapter read the status line |
| Works for the dev team, fails for everyone else | The team’s numbers are registered test numbers and never traverse the hook |
| Config change appears to do nothing | Propagation delay. Re-check after one to two minutes |
| Total auth failure with no code change | The hook endpoint is down. This is the lost fallback |
Summary
A provider outside the supported four is not grounds for replacing Supabase Auth, and it is certainly not grounds for writing OTP issuance yourself. Delivery is not security-bearing; generation, hash custody, expiry, throttling, and session issuance are. Delegate the first, keep the rest in GoTrue, and you satisfy regional delivery requirements, registered sender identities, in-country pricing, and regulatory standing while keeping every control the platform gives you. If you are choosing a stack, the rest of what we default to follows the same logic: keep the hard parts with whoever already solved them.