← Back to Blog

Supabase Auth With an Unsupported SMS Provider

Supabase Auth ships four SMS providers. Here is how to use any other one through the Send SMS hook while GoTrue keeps code generation, expiry, and throttling.

Supabase Auth ships with four SMS providers: MessageBird, Twilio, Vonage, and TextLocal, the last of which is community maintained. 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 natural trust boundary, hand only the delivery leg to an endpoint you control, and leave every security-relevant operation with GoTrue. What follows is the protocol contract, how to get the signature verification right, adapters for three provider API shapes, the config surface, and the one operational consequence that generates production incidents.

The alternative people reach for, abandoning Supabase Auth and writing OTP issuance in application code, makes your security materially worse and is unnecessary.

The phone OTP state machine

To reason about which component you can replace, decompose the flow first. A phone OTP cycle is two client-initiated transactions separated by an out-of-band delivery.

Issuance. The client submits a phone number in E.164 form. The auth server generates a code of configured length, hashes and stores it against the phone number, stamps it with an expiry derived from sms_otp_exp, and enforces two independent throttles: a per-number minimum interval governed by sms_max_frequency, and a project-wide ceiling governed by the SMS rate limit. Only after all of that does delivery happen.

Verification. The client submits the phone number and a candidate code. The server recomputes the hash, compares it against the stored value, checks the expiry, evaluates an attempt counter, and on success mints a session: an access token, conventionally a JWT with a bounded lifetime, and a refresh token subject to rotation.

Between those two sits one operation that isn’t security-bearing in any meaningful sense: moving six digits across a telecom network. It has no custody of the hash, no authority over expiry, no part in rate limiting, and no role in session issuance. It’s pure transport.

That’s the seam. The Send SMS hook exists to cut along it.

When you actually need to replace delivery

Four providers is adequate for deployments terminating in North America and Western Europe. Four constraints, each routinely hit outside those regions, make it inadequate.

Regulatory registration regimes

The binding case is India, where TRAI mandates registration of commercial communications on a Distributed Ledger Technology platform. Compliance is a chain of registrations, not one: the sending legal entity is registered, the sender identity (the header) is registered against that entity, and every message body is registered as a template with its variable substitution points declared in advance. Traffic that doesn’t resolve to an approved entity, header, and template triple gets filtered at the operator.

The consequences reach into application design. Message copy is fixed at registration time and can’t be varied from code, so you can’t A/B test your OTP message wording. Template approval takes business days, not a deploy. And an aggregator without standing in that regime can’t deliver transactional traffic at all, however willing they are commercially.

Comparable regimes are in force or emerging in several other jurisdictions. The implication is the same: provider selection is determined by regulatory standing, not engineering preference.

Interconnect economics

Per-message pricing for an in-country aggregator with direct operator interconnects is frequently a fraction of what a global vendor charges to terminate into the same network. At authentication volumes, where every session costs a message, that delta is load-bearing on your unit economics. A provider decision that’s immaterial at low volume becomes decisive at scale.

Delivery latency and completion rate

Direct interconnect affects latency distribution and completion rate, not just price. A code with a five-minute expiry that arrives at forty seconds has burned a real fraction of its validity window and converts measurably worse at the verification step. Tail latency on international routes is the dominant contributor to abandoned sign-ins in markets where local alternatives exist.

Providers you already have

An organisation that already holds a registered sender identity, an approved template set, a negotiated rate, and a compliance history with a provider has accumulated real switching cost. Throwing that away to satisfy a dropdown inverts the priority ordering.

Put those together and the realistic candidate set for many deployments is MSG91, Gupshup, Kaleyra, or Fast2SMS in India; Termii or Africa’s Talking across African markets; Infobip, Plivo, Sinch, or AWS SNS as global alternatives; or a direct SMPP gateway to an operator. None appears in the supported list. All are reachable through the hook.

flowchart TD
    A[Phone OTP authentication required] --> B{"Is the intended provider MessageBird,<br/>Twilio, Vonage or TextLocal?"}
    B -- Yes --> C["Use the native integration.<br/>Supply credentials. No further work."]
    B -- No --> D{"Does any hard constraint block<br/>migrating to one of those four?"}
    D -- "No binding constraint" --> C
    D -- "Registration regime, interconnect<br/>pricing, sender identity, latency" --> E[Enable the Send SMS hook]
    E --> F["GoTrue keeps generation, hash custody,<br/>expiry, throttling, session issuance"]
    E --> G["Your endpoint takes delivery only"]
    G --> H["Any provider, WhatsApp,<br/>or voice channel"]

The decision surface is narrower than it looks. The only real question is whether a binding constraint blocks the native list. If not, use it. If so, the right move isn’t to replace the auth subsystem, it’s to replace exactly one component inside it.

How the delegation is shaped

The trust boundary

Enabling the hook moves a single responsibility across the process boundary. Here’s who owns what afterwards.

ResponsibilityOwner after delegation
Code generation and entropy sourceGoTrue
Hashing and storage of the code at restGoTrue
Expiry evaluation, set by sms_otp_expGoTrue
Per-number send throttle, set by sms_max_frequencyGoTrue
Project-wide send rate ceilingGoTrue
Verification attempt limitingGoTrue
Session minting, refresh token rotation, revocationGoTrue
Getting the code onto a handsetYour endpoint

Every row but the last is a security control with a well-documented failure literature. Implementations that move those rows into application code show up in incident reports with a consistent signature: codes stored in cleartext, no verification attempt limiting (which reduces a six-digit code to a trivially enumerable search space), tokens issued without a bounded lifetime, and debug bypass paths that survive into production builds. Keeping GoTrue in custody of those rows is the main argument for the hook over a hand-rolled implementation, and it’s worth saying out loud in any design review where someone proposes the alternative.

Control flow

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 project 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
            Note over P: Message lands on the handset
            H-->>S: 200 {}
            S-->>C: Issuance acknowledged
        else Provider rejected or unreachable
            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 attempt count
    S-->>C: Session: access token plus refresh token

The thing to internalise from that diagram is where the hashing sits. It happens before the hook is called, and the comparison at verification succeeds or fails without any participation from your endpoint. Your endpoint sees the code exactly once, in transit, and holds no authority over whether it’s accepted. That’s what makes the delegation safe.

Request payload

The hook receives a JSON document with the user record and the generated code.

{
  "user": {
    "id": "6481a5c1-3d37-4a56-9f6a-bee08c554965",
    "aud": "authenticated",
    "role": "authenticated",
    "phone": "+1333363128",
    "phone_confirmed_at": "2024-05-13T11:52:48.157306Z",
    "app_metadata": { "provider": "phone", "providers": ["phone"] },
    "user_metadata": {},
    "created_at": "2024-05-13T11:45:33.7738Z"
  },
  "sms": { "otp": "561166" }
}

Two fields are load-bearing: user.phone, delivered in E.164 form, and sms.otp. The remaining user attributes are available for routing decisions, like picking a provider by country code prefix, but nothing required depends on them.

Response contract

An empty object with status 200 means the message was dispatched.

{}

A failure goes back in a structured envelope that GoTrue propagates to the calling client.

{ "error": { "http_code": 502, "message": "sms delivery failed" } }

Returning 200 on a dispatch that actually failed is a correctness bug with a terrible observable signature. GoTrue records the issuance as successful, the client gets an acknowledgement, and the user waits on a message that will never arrive with no error surfaced anywhere in the system. Propagate provider failure faithfully.

Authenticating the request

Threat model

The hook endpoint is an unauthenticated HTTPS resource, reachable from the public internet, whose invocation causes messages to be dispatched and billed to your provider account. It receives live codes. Two things follow.

Without origin authentication, anyone who discovers the URL can submit arbitrary payloads. Depending on your implementation that means message dispatch to attacker-chosen destinations at your expense, which is both a direct financial loss and a reputational exposure against your registered sender identity.

Without replay protection, a single intercepted request stays valid indefinitely, so a captured payload can be resubmitted without limit.

The signature scheme addresses both, if you implement it correctly. Implemented incorrectly, it gives you the appearance of protection without the substance.

The Standard Webhooks scheme

Supabase signs hook invocations according to the Standard Webhooks specification. The provisions that matter:

Three headers accompany each request: webhook-id, webhook-timestamp, and webhook-signature.

The signed content is the concatenation msg_id.timestamp.payload, with full stop delimiters, where payload is the request body as transmitted.

The signature header carries a space-delimited list of signatures rather than a single value. That’s deliberate. It gives you a secret rotation window during which both the outgoing and incoming secrets produce valid signatures, so rotation doesn’t need a synchronised deployment.

Symmetric secrets are base64 encoded and carry a whsec_ prefix. Supabase presents the value as v1,whsec_<base64>, with a scheme version prefix ahead of the secret prefix.

The spec mandates a timestamp tolerance check to defeat replay but leaves the window duration to you. Five minutes is a defensible default: wide enough to absorb clock skew between your host and the auth service, narrow enough to bound the replay window.

Reference implementation

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export interface WebhookHeaders {
  id: string | undefined;
  timestamp: string | undefined;
  signature: string | undefined;
}

export function verifyStandardWebhook(
  secret: string,
  headers: WebhookHeaders,
  rawBody: string,
  nowSeconds = Math.floor(Date.now() / 1000),
): boolean {
  const { id, timestamp, signature } = headers;
  if (!id || !timestamp || !signature) return false;

  // Replay window. Without this bound, an intercepted request is
  // indefinitely resubmittable.
  const ts = Number(timestamp);
  if (!Number.isFinite(ts) || Math.abs(nowSeconds - ts) > TOLERANCE_SECONDS) {
    return false;
  }

  // Strip the scheme version prefix and the symmetric secret prefix,
  // 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();

  // Space delimited list so that a rotation period can present both the
  // superseded and the current signature simultaneously.
  return signature.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    if (version !== "v1" || !value) return false;
    const given = Buffer.from(value, "base64");
    // Length check first: timingSafeEqual throws on length mismatch.
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

Verification is an ordered gate sequence

Those checks aren’t a set of independent assertions. They’re an ordered sequence where each gate is a precondition for the next, and dropping any one of them is a real vulnerability rather than a style choice.

flowchart TD
    A[Request received] --> B["Capture the raw body bytes<br/>before any parsing"]
    B --> C{"webhook-id, webhook-timestamp and<br/>webhook-signature all present?"}
    C -- No --> R["401 invalid signature"]
    C -- Yes --> D{"Timestamp within tolerance?"}
    D -- "No: outside replay window" --> R
    D -- Yes --> E["Decode secret: strip 'v1,' and<br/>'whsec_' prefixes, base64 decode"]
    E --> F["Compute HMAC-SHA256 over<br/>id.timestamp.rawBody"]
    F --> G{"Does any candidate in the space<br/>delimited list match under<br/>constant-time comparison?"}
    G -- No --> R
    G -- Yes --> H["Parse and dispatch"]

The four ways people get this wrong

Hashing a re-serialised body. The MAC is computed over the exact bytes transmitted. A framework that parses JSON into an object, followed by application code that re-serialises it for verification, will generally produce different bytes: key ordering isn’t guaranteed to survive, insignificant whitespace is dropped, and numeric and string escaping may be normalised. The hash will never match. Capture the raw body, verify against it, and parse only after verification succeeds. In frameworks that eagerly parse request bodies, this needs explicit config to retain the raw buffer.

Skipping the timestamp bound. Signature verification without a replay window authenticates origin but not freshness. Anyone positioned to capture one valid request can resubmit it forever.

Parsing a single signature instead of the list. An implementation that treats the header as a scalar verifies correctly in steady state and fails at the exact moment a secret rotation is in progress, which is a total authentication outage at the least convenient time. The list form exists to make rotation non-disruptive, and parsing it as a scalar discards that property silently.

Comparing hashes with ===. String and buffer equality operators short-circuit on the first difference, so they leak information about the correct hash through execution time. Use a constant-time comparison, guarded by a length check first, since those primitives conventionally throw on length mismatch rather than returning false.

The endpoint

import { verifyStandardWebhook } from "./standard-webhooks";
import { sendSms } from "./sms-transport";

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");

  // Raw bytes, captured before any parsing.
  const rawBody = await req.text();

  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) as {
    user?: { phone?: string };
    sms?: { otp?: string };
  };
  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) {
    // Log the delivery failure. Never log the code.
    console.error("[sendSmsHook]", e);
    return err(502, "sms delivery failed");
  }
}

The handler is stateless. It persists nothing, holds no session, and needs no database. That’s deliberate: no durable state to corrupt, no migration surface, and a recovery procedure that consists entirely of redeploying.

One prohibition is worth stating on its own. The code must not be written to any log sink, error aggregator, distributed trace, or request archive. For the length of its expiry window it’s a live credential for that phone number. Structured logging that serialises whole request objects is the usual vector, and it usually gets introduced long after the endpoint is written, by a change to shared logging middleware rather than to the handler.

Provider adapters

sendSms is the only part that varies across providers. Three API shapes cover most of what’s commercially available.

Bearer-authenticated JSON API

The prevailing modern shape among both global vendors and regional aggregators.

export async function sendSms(recipient: string, message: string): Promise<void> {
  const token = process.env.SMS_API_TOKEN;
  if (!token) throw new Error("SMS_API_TOKEN is not set");

  const response = await fetch("https://api.example-provider.com/v1/sms/send", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({
      recipient,                              // E.164, forwarded unmodified
      sender_id: process.env.SMS_SENDER_ID,   // provisioned header
      type: "plain",
      message,
    }),
    // Bound the call. An unbounded provider call blocks the hook, which
    // blocks the issuance transaction, which looks to the user like a
    // frozen app.
    signal: AbortSignal.timeout(10_000),
  });

  const json: any = await response.json().catch(() => null);

  // A lot of SMS APIs return HTTP 200 with a failure flag in the body.
  // Check both.
  if (!response.ok || json?.status !== "success") {
    throw new Error(
      `provider rejected: http ${response.status} ${json?.message ?? ""}`.trim(),
    );
  }
}

Template-referenced API, for registration regimes

Where regulation mandates pre-registered message templates, the request carries a template identifier 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,   // registered with the operator
      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 adapter takes the code as a discrete variable rather than an interpolated message, because the surrounding copy is fixed at registration. If your codebase has to support both regimes, the adapter interface should take a structured argument, { recipient, otp, purpose }, and let each implementation decide whether it interpolates or binds. An interface that only accepts a rendered string can’t express the template case.

Cloud provider SDK

import { SNSClient, PublishCommand } from "@aws-sdk/client-sns";

const sns = new SNSClient({ region: process.env.AWS_REGION });

export async function sendSms(recipient: string, message: string): Promise<void> {
  await sns.send(
    new PublishCommand({
      PhoneNumber: recipient,
      Message: message,
      MessageAttributes: {
        "AWS.SNS.SMS.SMSType": {
          DataType: "String",
          StringValue: "Transactional",
        },
      },
    }),
  );
}

Rules that hold for every adapter

Set an explicit timeout. The default timeout of most HTTP clients is either absent or measured in minutes. The hook call is synchronous with respect to the user’s sign-in request, so an unbounded provider call turns a provider slowdown into an apparent app hang.

Check the response body, not just the status line. Returning 200 with an in-body failure flag is common enough across SMS APIs that you should assume it until you’ve verified otherwise for a given vendor.

Forward the phone number in the form you received it. GoTrue gives you E.164. If a provider wants a national format or a bare international number with no plus sign, do that normalisation inside the adapter. Normalising upstream couples the hook handler to one provider’s quirk and breaks the moment you add a second.

Configuration

Dashboard

Authentication, then Hooks, then Send SMS hook, configured as an HTTPS endpoint. The generated secret is shown once and has to be copied into the endpoint’s environment as SEND_SMS_HOOK_SECRET.

Management API

Configuring through the Management API is better for anything past a prototype, because it’s reproducible, reviewable, and expressible in version control alongside your schema 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_length": 6,
    "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": "Your code is {{ .Code }}"
  }'

Field semantics: sms_otp_exp is the validity window in seconds. sms_max_frequency is the minimum interval in seconds between successive sends to a single number. sms_template is the message body with {{ .Code }} as the substitution point, and it’s read only by native providers, not by the hook, whose message body your adapter composes.

Config changes through this API are not guaranteed to be immediately visible to the running auth service. We’ve observed propagation on the order of one to two minutes. During that interval the service keeps operating under the previous config, which means a newly enabled hook may not be called yet, and newly registered test numbers may still be treated as live. Validate after a delay before concluding a config change failed.

Postgres function as an alternative hook target

Supabase hooks can also be implemented as Postgres functions addressed by the pg-functions:// scheme, granted to the supabase_auth_admin role and issuing outbound requests through pg_net. That removes a separately deployed and separately operated service from the critical auth path, which is a real reduction in surface area. The published examples for the Send SMS hook demonstrate the HTTPS endpoint form, so the function route is less well trodden, and it constrains you to what pg_net can express. For a provider that’s a single HTTP POST with a bearer token, it’s worth weighing against the operational cost of running a separate service.

What enabling the hook costs you

Two consequences are underrepresented in the available material, and they’re the ones that generate incidents.

The native provider config goes inert

Supabase states this directly in the dashboard: SMS provider settings are disabled while the SMS hook is enabled. Credentials for whichever native provider is still selected are no longer read. This is a common source of wasted debugging, where an engineer investigates credentials that aren’t on any execution path.

Note the distinction between two separate settings. The phone provider itself has to stay enabled, since that flag controls whether phone-based auth is available at all. Only the provider selection underneath it goes inert.

You lose your delivery fallback

This is the substantive trade, and it belongs in any design review that approves the change.

Before delegation, delivery availability is Supabase’s operational responsibility, discharged against their vendor relationships and their reliability engineering. After delegation, your endpoint is a hard dependency of sign-in with no configured alternative. If it returns an error, exceeds its timeout, or fails to resolve, signInWithOtp fails and no user anywhere can establish a session. The blast radius is total. The failure isn’t partial or degraded, it’s complete.

It’s also poorly observable from your usual vantage points. Application dashboards report reduced traffic rather than an error condition, because the failing requests never reach your app. The signal lives on the hook endpoint alone.

Mitigations, cheapest first:

Host the endpoint at the availability tier of your database, not of an ancillary service. It now carries the availability requirement of authentication itself, and its deployment target should reflect that.

Bound the provider call and allow at most one retry. Sign-in is synchronous with a waiting user, so an aggressive retry policy turns a provider slowdown into a timeout.

Alert specifically on the hook endpoint’s error rate and latency. A rising rate of 502s on that route is a total authentication outage in progress. Make the alert page someone.

Where auth availability is revenue-critical, put a second provider behind the same adapter interface and fail over on error. One interface, two implementations, selected by health check. The incremental cost is one contract and one adapter. The benefit is removing a single point of failure from the auth path.

Testing this

Validating the integration shouldn’t consume live messages, and it must not do so in CI.

Unit test the verifier, not the provider

Signature verification is the component whose regressions are silent. The provider adapter fails loudly in staging. The verifier fails only against a correctly signed request, which a naive test never constructs. Three assertions give you adequate coverage.

Construct a request, sign it with a known secret, assert that verification succeeds. Mutate a single byte of the body, assert that verification fails. Backdate the timestamp past the tolerance window, assert that verification fails.

Those three cases exercise the three properties of the scheme: correctness, integrity, and freshness.

Use registered test numbers for end-to-end validation

Supabase supports registering specific phone numbers against fixed codes. For a registered number, the service short-circuits before delivery: the hook isn’t called, no message is dispatched, and nothing is billed. That’s the right mechanism for end-to-end tests, developer onboarding, and CI.

It’s also what lets an App Store or Play Store reviewer, who can’t receive SMS terminating in your market, authenticate against a submitted build. That case has its own constraints and its own failure modes, covered separately in how to let an App Store reviewer log into a phone-OTP app.

Instrument the send without instrumenting the credential

Record the phone number, the provider’s response flag, the observed latency, and the outcome. Don’t record the code. Where shared logging middleware serialises request or response bodies, add an explicit redaction rule for the sms.otp path rather than trusting the handler to avoid logging it.

Failure mode catalogue

SymptomProbable cause
Uniform 401 from the endpoint under all conditionsHash computed over a re-serialised body, or wrong secret decoding: both the v1, and whsec_ prefixes have to be stripped and the remainder base64 decoded
Verification succeeds locally, returns 401 in deploymentA proxy or framework middleware is mutating the body before capture. Retain the raw buffer at the edge
Verification starts failing right after a secret rotationThe signature header is being parsed as a scalar instead of a space-delimited list
Client reports an unsupported phone providerThe hook is enabled while phone auth itself is disabled. These are distinct settings
Sign-in request hangs and then times outNo timeout on the provider call
No message received and no error recorded anywhereThe provider returned 200 with an in-body failure flag and the adapter read the status line as success
Auth works for the dev team and fails for everyone elseThe team’s numbers are registered as test numbers and never traverse the hook
Config change appears to have no effectPropagation delay. Re-check after one to two minutes before diagnosing further
Complete auth failure with no corresponding code changeThe hook endpoint is down. This is the lost fallback described above

Summary

An SMS provider outside the supported four is not grounds for replacing Supabase Auth, and it’s certainly not grounds for writing OTP issuance in application code.

Split the flow at its natural trust boundary. Delivery isn’t security-bearing; generation, hash custody, expiry, throttling, and session issuance are. Hand the first to the Send SMS hook and keep the rest in GoTrue. The result satisfies regional delivery requirements, registered sender identities, in-country pricing, and regulatory standing, while preserving every security control the platform gives you. If you’re picking a stack for a new product, the rest of what we default to follows the same logic: keep the hard parts with whoever already solved them.

Three requirements decide whether your implementation is sound. Verify the signature against the raw request body, with a timestamp tolerance bound and a constant-time comparison. Set an explicit timeout on the provider call and check the response body rather than the status line alone. And write down, in whatever document governs your production dependencies, that the hook endpoint is now a single point of failure for all authentication, with the availability requirement that implies.

// frequently asked

Common questions

Which SMS providers does Supabase Auth support natively?
MessageBird, Twilio, Vonage, and TextLocal, the last of which is community maintained. Any provider outside that list needs the Send SMS hook.
Can MSG91, Gupshup, Kaleyra, Plivo, Infobip, or AWS SNS be used with Supabase Auth?
Yes, through the Send SMS hook. GoTrue sends the phone number and the generated code to an HTTPS endpoint you control, and that endpoint delivers through any provider you like. Code generation, hash storage, expiry, and verification all stay with GoTrue.
Do you lose rate limiting and throttling when you delegate SMS delivery?
No. The hook replaces delivery only. Code generation, hash storage, expiry, per-number send throttling, project-wide rate ceilings, and verification attempt limits all stay in GoTrue. Keeping those controls is the main reason to use the hook instead of writing your own OTP flow.
How is the Send SMS hook authenticated?
With Standard Webhooks signatures. Every request carries webhook-id, webhook-timestamp, and webhook-signature headers, the last holding an HMAC-SHA256 over the string id.timestamp.body keyed by a shared secret. Verify against the raw request body, not a re-serialised one, and reject timestamps outside a tolerance window.
Does enabling the hook disable the configured native provider?
Yes. Provider settings are not read while the hook is enabled. Phone auth itself has to stay enabled, since that flag controls whether phone login is available at all. Only the provider selection underneath it goes inert.
Can the hook deliver over WhatsApp or a voice call instead of SMS?
Yes. The contract gives you a phone number and a code and expects a 200 back. Nothing in it constrains the channel.
What is the main risk of using the Send SMS hook?
Your endpoint becomes a hard dependency of sign-in with no fallback. If it errors, times out, or fails to resolve, signInWithOtp fails and nobody can log in. The failure is also hard to see from your usual dashboards, because the failing requests never reach your app. Host the endpoint at the availability tier of your database and alert on its error rate.