Phone Authentication for Self-Hosted Supabase: SMS and OTP Setup

Configure SMS-based phone authentication for self-hosted Supabase with Twilio, MessageBird, and custom providers. Complete OTP setup guide.

Cover Image for Phone Authentication for Self-Hosted Supabase: SMS and OTP Setup

Phone authentication adds a layer of security and convenience that email alone can't match. For self-hosted Supabase deployments, setting up SMS-based OTP (one-time password) authentication requires configuring an external SMS provider—something that isn't automatically handled like it is on Supabase Cloud.

This guide walks you through configuring phone authentication for your self-hosted instance, covering Twilio, MessageBird, and custom SMS providers, plus the common pitfalls that trip up most deployments.

Why Phone Authentication Matters

Before diving into configuration, let's address when phone auth makes sense for your application:

Good use cases:

  • Two-factor authentication (2FA) for sensitive operations
  • Passwordless login in mobile-first applications
  • User verification where email isn't reliable
  • Applications requiring verified phone numbers (delivery, rideshare, etc.)

Trade-offs to consider:

  • SMS costs add up (roughly $0.01-0.05 per message depending on region)
  • SMS isn't cryptographically secure (SIM swapping attacks exist)
  • Requires third-party provider dependency
  • Some users prefer not to share phone numbers

Phone auth works best as a complement to other methods, not a replacement. For most self-hosted deployments, combining it with OAuth providers gives users flexibility.

Prerequisites

Before configuring phone auth, ensure you have:

  1. A working self-hosted Supabase deployment (deployment guide)
  2. SMTP configured for email templates
  3. An account with an SMS provider (Twilio, MessageBird, Vonage, or TextLocal)
  4. Access to your docker-compose.yml and .env files

Understanding the Architecture

Phone authentication in Supabase uses the GoTrue auth service. When a user requests an OTP:

  1. GoTrue generates a random OTP and hashes it for storage
  2. GoTrue calls your configured SMS provider API
  3. The provider delivers the SMS to the user
  4. User enters the OTP, GoTrue verifies against the hash

This architecture means you can't retrieve OTPs from the database—they're stored hashed. If SMS delivery fails, users need to request a new code.

Configuring Twilio

Twilio is the most commonly used SMS provider with Supabase. There are two options: standard Twilio SMS and Twilio Verify.

Standard Twilio SMS

Add these environment variables to your .env file:

# Enable phone signup
ENABLE_PHONE_SIGNUP=true
ENABLE_PHONE_AUTOCONFIRM=false

# Twilio configuration
GOTRUE_SMS_PROVIDER=twilio
GOTRUE_SMS_TWILIO_ACCOUNT_SID=your_account_sid
GOTRUE_SMS_TWILIO_AUTH_TOKEN=your_auth_token
GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID=your_messaging_service_sid

Then update your docker-compose.yml to pass these variables to the auth container:

auth:
  environment:
    GOTRUE_SMS_PROVIDER: ${GOTRUE_SMS_PROVIDER}
    GOTRUE_SMS_TWILIO_ACCOUNT_SID: ${GOTRUE_SMS_TWILIO_ACCOUNT_SID}
    GOTRUE_SMS_TWILIO_AUTH_TOKEN: ${GOTRUE_SMS_TWILIO_AUTH_TOKEN}
    GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: ${GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID}

Twilio Verify

Twilio Verify handles OTP generation and validation on Twilio's side, which some teams prefer for compliance reasons:

GOTRUE_SMS_PROVIDER=twilio_verify
GOTRUE_SMS_TWILIO_ACCOUNT_SID=your_account_sid
GOTRUE_SMS_TWILIO_AUTH_TOKEN=your_auth_token
GOTRUE_SMS_TWILIO_VERIFY_SERVICE_SID=your_verify_service_sid

Note the different SID: Verify uses a Verify Service SID, not a Messaging Service SID.

Configuring MessageBird

MessageBird offers competitive pricing for certain regions. Configuration:

GOTRUE_SMS_PROVIDER=messagebird
GOTRUE_SMS_MESSAGEBIRD_ACCESS_KEY=your_access_key
GOTRUE_SMS_MESSAGEBIRD_ORIGINATOR=YourAppName

The originator can be a phone number (for countries requiring it) or an alphanumeric sender ID (11 characters max).

Pass these to docker-compose:

auth:
  environment:
    GOTRUE_SMS_PROVIDER: ${GOTRUE_SMS_PROVIDER}
    GOTRUE_SMS_MESSAGEBIRD_ACCESS_KEY: ${GOTRUE_SMS_MESSAGEBIRD_ACCESS_KEY}
    GOTRUE_SMS_MESSAGEBIRD_ORIGINATOR: ${GOTRUE_SMS_MESSAGEBIRD_ORIGINATOR}

Configuring Vonage (Nexmo)

Vonage, formerly Nexmo, is another option:

GOTRUE_SMS_PROVIDER=vonage
GOTRUE_SMS_VONAGE_API_KEY=your_api_key
GOTRUE_SMS_VONAGE_API_SECRET=your_api_secret
GOTRUE_SMS_VONAGE_FROM=YourAppName

OTP Settings and Rate Limits

Default settings are often too restrictive for production. Adjust these:

# OTP expiration (default 60 seconds - usually too short)
GOTRUE_SMS_OTP_EXP=300  # 5 minutes

# Minimum time between OTP requests (rate limiting)
GOTRUE_SMS_MAX_FREQUENCY=60  # seconds between requests

# Global rate limit for SMS per hour
GOTRUE_RATE_LIMIT_SMS_SENT=100  # default is 30

The 60-second default OTP expiration catches many developers off guard. Users on slower networks or in areas with SMS delays often find their codes expire before arrival. Five minutes is a reasonable balance between security and usability.

Testing Without Sending SMS

During development, use test phone numbers to avoid SMS costs:

# Test OTP that always works for specific numbers
SMS_TEST_OTP=123456
SMS_TEST_OTP_VALID_UNTIL=2026-12-31T23:59:59Z

Combined with a test phone number like +15555550100, this lets you test the auth flow without actually sending SMS.

Warning: Remove test OTPs before production deployment. The SMS_TEST_OTP_VALID_UNTIL setting provides a safety net—test OTPs automatically stop working after this date.

Implementing Phone Auth in Your App

With the server configured, implement phone auth in your application:

Sign Up with Phone

const { data, error } = await supabase.auth.signUp({
  phone: '+12345678901',
  password: 'user-password', // Optional if using passwordless
})

Request OTP for Existing User

const { data, error } = await supabase.auth.signInWithOtp({
  phone: '+12345678901',
})

Verify OTP

const { data, error } = await supabase.auth.verifyOtp({
  phone: '+12345678901',
  token: '123456',
  type: 'sms',
})

Using Phone as Second Factor (MFA)

Phone-based MFA adds security to password or social logins. Enable it:

GOTRUE_MFA_ENABLED=true

Then in your application, enroll users for phone MFA:

// Enroll phone for MFA
const { data, error } = await supabase.auth.mfa.enroll({
  factorType: 'phone',
  phone: '+12345678901',
})

// Create challenge (sends SMS)
const { data: challenge } = await supabase.auth.mfa.challenge({
  factorId: data.id,
})

// Verify challenge
const { data: verify } = await supabase.auth.mfa.verify({
  factorId: data.id,
  challengeId: challenge.id,
  code: '123456',
})

Row-Level Security Considerations

Phone-authenticated users receive the authenticated role like email users. If you need to differentiate:

-- Check if user signed up with phone
CREATE POLICY "phone_users_policy" ON your_table
FOR SELECT
USING (
  auth.jwt() ->> 'phone' IS NOT NULL
);

Custom SMS Providers

If Twilio, MessageBird, and Vonage pricing doesn't work for your regions, use the Send SMS Hook. This feature lets you intercept OTP requests and route them through any provider.

Create an Edge Function that receives the OTP and handles delivery:

// supabase/functions/custom-sms/index.ts
import { serve } from 'https://deno.land/[email protected]/http/server.ts'

serve(async (req) => {
  const { phone, otp } = await req.json()
  
  // Call your custom SMS provider
  await fetch('https://your-sms-provider.com/send', {
    method: 'POST',
    body: JSON.stringify({
      to: phone,
      message: `Your verification code is: ${otp}`,
    }),
  })
  
  return new Response(JSON.stringify({ success: true }))
})

Then configure the hook in your auth settings.

Common Issues and Troubleshooting

"Rate limit exceeded" Errors

Check both SMS_MAX_FREQUENCY (per-user limit) and GOTRUE_RATE_LIMIT_SMS_SENT (global limit). During testing, you might hit limits quickly.

OTPs Not Arriving

  1. Verify SMS provider credentials are correct
  2. Check that environment variables are passed through docker-compose
  3. Review auth container logs: docker logs supabase-auth
  4. Test with a phone number in a different region (some providers have regional issues)

"Invalid OTP" When Code is Correct

  • OTP may have expired (check GOTRUE_SMS_OTP_EXP)
  • User may be submitting wrong type parameter (use sms for primary phone, phone_change for phone updates)
  • Leading zeros in OTP might be stripped by user input

Phone Number Format Issues

Always use E.164 format: +[country code][number]. For US: +12025551234, not (202) 555-1234.

// Client-side normalization
function normalizePhone(phone) {
  const digits = phone.replace(/\D/g, '')
  if (digits.length === 10) {
    return `+1${digits}` // Assume US
  }
  return `+${digits}`
}

Docker Network Issues

If using Edge Functions for SMS hooks, remember that localhost inside a container refers to the container itself. Use service names like http://kong:8000/ for internal calls.

Cost Comparison

Rough per-SMS costs (2026 pricing, US destination):

ProviderCost per SMSNotes
Twilio$0.0079Higher for international
MessageBird$0.006Competitive for EU
Vonage$0.0068Good API
TextLocal$0.003Community-supported

For 10,000 monthly authentications with 2 SMS per auth (verification + confirmation), expect $150-300/month in SMS costs.

Managing Phone Auth with Supascale

Configuring SMS providers through environment variables and docker-compose files works, but it's tedious for multi-project deployments. Supascale provides a UI for managing auth configurations across your self-hosted instances:

  • Configure SMS providers without editing YAML files
  • Set OTP expiration and rate limits per project
  • View auth logs for debugging delivery issues
  • Manage multiple projects from a single dashboard

For teams running several self-hosted Supabase projects, this eliminates the copy-paste configuration across environments. Check the pricing page for details.

Further Reading