Supabase SMS codes from your own number: the Send SMS hook

Checked against the docs:

You enable an HTTPS Send SMS hook in Supabase and point it at an Edge Function. Supabase Auth posts signed JSON with user.phone and sms.otp, the function verifies it with the standardwebhooks library, calls POST https://smsportal.app/api/v1/gateway/send-sms with an x-api-key header and returns {}. The code arrives from your number. Raise code validity from the default 60 seconds to 5 minutes.

What you need

  • A Supabase project (the Send SMS hook is available on the Free plan too) and the Supabase CLI.
  • A smsportal account with an Android phone and SIM card connected, plus an API key (API Keys → Generate API Key).
  • Small or internal scale: one phone sends about 720 texts an hour and each takes a few seconds to arrive.

How to send Supabase codes by SMS from your own number

  1. Create the Edge Function

    Run supabase functions new send-sms and paste the code into index.ts. The function checks the signature from the webhook-id, webhook-timestamp and webhook-signature headers, sends the SMS and returns an empty JSON object. It gives the API call 4 seconds, because Supabase waits at most 5 seconds for the hook. On failure it returns status 200 with an error object: only then does your app get a readable message.

    send-sms Edge Function
    // supabase/functions/send-sms/index.ts
    import { Webhook } from 'npm:standardwebhooks@1.0.0'
    
    interface SendSmsEvent {
      user: { phone?: string }
      sms: { otp: string; phone?: string }
    }
    
    const SMSPORTAL_URL = 'https://smsportal.app/api/v1/gateway/send-sms'
    const apiKey = Deno.env.get('SMSPORTAL_API_KEY') ?? ''
    const hookSecret = (Deno.env.get('SEND_SMS_HOOK_SECRET') ?? '').replace('v1,whsec_', '')
    
    const json = (body: unknown, status = 200) =>
      new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
    
    // Supabase Auth passes an error message to your app only from a 200 JSON response.
    const authError = (message: string) => json({ error: { http_code: 500, message } })
    
    Deno.serve(async (req) => {
      const payload = await req.text()
      let event: SendSmsEvent
      try {
        event = new Webhook(hookSecret).verify(payload, Object.fromEntries(req.headers)) as SendSmsEvent
      } catch {
        return json({ error: 'invalid signature' }, 401)
      }
    
      // Supabase stores numbers without "+"; on a phone change the target is sms.phone.
      const raw = event.sms.phone || event.user.phone || ''
      const to = raw.startsWith('+') ? raw : '+' + raw
      const message = event.sms.otp + ' is your login code. Valid for 5 minutes. Do not share it.'
    
      try {
        const res = await fetch(SMSPORTAL_URL, {
          method: 'POST',
          headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
          body: JSON.stringify({ recipients: [to], message }),
          signal: AbortSignal.timeout(4000),
        })
        if (!res.ok) {
          console.error('smsportal', res.status, await res.text())
          return authError('Could not send the SMS code. Try again in a minute.')
        }
      } catch (err) {
        console.error('smsportal unreachable', err)
        return authError('Could not send the SMS code. Try again in a minute.')
      }
    
      return json({})
    })
  2. Enable the hook in the dashboard

    In the Supabase dashboard open Authentication → Auth Hooks → Add hook → Send SMS hook. Turn on Enable Send SMS hook, Hook type: HTTPS, URL: https://<project-ref>.supabase.co/functions/v1/send-sms. Click Generate secret, copy the v1,whsec_... secret and save the hook.

  3. Set secrets and deploy

    Store the smsportal key and the hook secret as function secrets, then deploy with --no-verify-jwt: Supabase Auth calls the hook without a user token, and the signature proves authenticity. The function reads new secrets immediately, without a redeploy.

    Supabase CLI
    supabase secrets set SMSPORTAL_API_KEY=YOUR_API_KEY
    supabase secrets set SEND_SMS_HOOK_SECRET='v1,whsec_PASTE_THE_DASHBOARD_SECRET'
    supabase functions deploy send-sms --no-verify-jwt
  4. Enable phone sign-in and extend code validity

    In Authentication → Sign In / Providers → Phone turn on Enable Phone provider. With the hook on, SMS provider credentials (Twilio and others) are not required. Set SMS OTP Expiry to 300 seconds: the default 60 seconds is too short when the text goes through a phone and may wait in its queue.

  5. Test with your own number

    Call signInWithOtp with a number like +14155550123 and pass the code from the SMS to verifyOtp. The function logs (Edge Functions → send-sms → Logs) show every non-2xx response from smsportal.

    Test with supabase-js
    // phone sign-in (supabase-js)
    const { error } = await supabase.auth.signInWithOtp({ phone: '+14155550123' })
    
    // after the user types the code from the SMS
    const { data, error: verifyError } = await supabase.auth.verifyOtp({
      phone: '+14155550123',
      token: '123456',
      type: 'sms',
    })

Ready-made scenarios

  • Phone sign-in for a client portal

    A small customer portal or internal tool signs users in with an SMS code. Customers see your number, so they know who is texting them.

  • Confirming a new phone number

    When a user changes their number with updateUser({ phone }), Supabase sends a code to the new number. The function in this recipe reads it from sms.phone, so the code does not go to the old number.

  • An alert when a code does not go out

    Add a smsportal webhook on MESSAGE_FAILED and forward it by email or to Slack. An offline phone means codes sit in the queue and are invalid after 5 minutes. A fast alert lets you switch sign-in to email.

Errors and fixes

Your app gets Hook requires authorization token
Cause: The function was deployed without --no-verify-jwt, or the hook secret is wrong, so the function answers 401.
Fix: Redeploy with --no-verify-jwt. Check that SEND_SMS_HOOK_SECRET matches the secret shown in Auth Hooks.
Failed to reach hook within maximum time of 5 seconds
Cause: The function answers too slowly, for example with no timeout on the API call.
Fix: Keep AbortSignal.timeout(4000) from the recipe. The smsportal call only queues the SMS, so it usually takes a fraction of a second.
The code arrives but verifyOtp says it expired
Cause: Supabase SMS codes are valid for 60 seconds by default, and a text from a phone takes a few seconds or longer.
Fix: Set SMS OTP Expiry to 300 seconds. Do not go past 10 minutes: NIST says such a code must then be treated as invalid.
429 over_sms_send_rate_limit
Cause: A Supabase limit: by default 30 SMS per hour per project and one code per number every 60 seconds.
Fix: Raise the limit in Authentication → Rate Limits. Keep the 60-second per-number limit, it stops people flooding someone with codes.
Function logs show smsportal 400 or 429
Cause: 400: no enabled phone or the account email is not verified. 429: the SMS limit of your plan is used up.
Fix: Check the phone status in the smsportal dashboard or change plans on the pricing page.

Frequently asked questions

Does Supabase still send through Twilio when the hook is on?

No. With the Send SMS hook enabled, Supabase Auth calls your function instead of the built-in provider. Supabase still generates and checks the code; you only handle delivery.

Does this work for phone MFA too?

Yes, phone MFA uses the same configuration and the same hook. In Supabase it is a paid add-on (Advanced MFA Phone) on Pro and higher plans.

What does a code to another country cost?

smsportal charges no per-message fee. A code within your SIM's country costs what a text costs on your plan, often nothing. An international code costs whatever your carrier charges for an international SMS.

Is SMS from your own number secure 2FA?

Better than a password alone, weaker than a TOTP app or a passkey, because a number can be hijacked through a SIM swap. It is fine for a small portal. The OTP article compares the methods.

What if I use Better Auth or my own backend?

Same idea: your code generates the OTP and calls `send-sms` with the `x-api-key` header. The OTP article has a ready Node.js example.

Sources

  1. Supabase: Send SMS Hook
  2. Supabase: Auth Hooks (signatures, errors, timeouts)
  3. Supabase: Phone Login
  4. Supabase: Rate limits
  5. Standard Webhooks
  6. smsportal API documentation

More in the guides

Send your first SMS today

Create a free account, connect your phone and see how customers respond to messages from your number.