Skip to content

Voice OTP Authentication Setup

Phone-based sign-in for theaccessible.org. The Supabase Auth runtime generates a 6-digit OTP as usual, but instead of sending an SMS we intercept the payload in a Send SMS Hook Edge Function and place a Twilio voice call that reads the code aloud. This avoids the multi-day Twilio toll-free SMS-verification wait and works with any Voice-enabled Twilio number immediately.

A clean deployment can be configured end-to-end in under 15 minutes.


1. Provision a Twilio voice number

  1. Sign in to https://console.twilio.com/.
  2. Phone Numbers β†’ Manage β†’ Buy a number β†’ tick Voice capability, pick a local US number (~$1.15/mo).
  3. Once purchased, open the number’s config page and confirm Voice is enabled. Leave the β€œA call comes in” webhook empty β€” we use inline TwiML.
  4. From the Account Info panel on the Twilio dashboard, copy:
    • Account SID (starts with AC…)
    • Auth Token (use View to reveal)
    • Your new phone number in E.164 format (e.g. +14155551212)

2. Generate the hook shared secret

Supabase uses Standard Webhooks signing for HTTPS auth hooks, so the secret must be in the v1,whsec_<base64> form. Generate one with:

Terminal window
echo "v1,whsec_$(openssl rand -base64 32)"

Keep this value handy β€” it goes in two places (Supabase Dashboard AND the Edge Function secret store). Both must match exactly.

3. Set Edge Function secrets

Terminal window
supabase secrets set \
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
TWILIO_VOICE_NUMBER=+14155551212 \
SEND_SMS_HOOK_SECRET=<the value from step 2>

SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are auto-injected by the Supabase runtime β€” do not set them yourself.

4. Deploy the Edge Function

Terminal window
supabase functions deploy send-sms-hook --project-ref <ref>

Smoke-test it returns 401 to an unauthenticated request:

Terminal window
curl -sI https://<ref>.supabase.co/functions/v1/send-sms-hook
# expect: HTTP/2 401

5. Enable phone provider + Send SMS Hook in the Supabase Dashboard

supabase/config.toml declares the desired state, but the production project needs the same wiring applied via Dashboard:

  1. Auth β†’ Providers β†’ Phone β†’ toggle Enable phone provider on. Leave the built-in Twilio SMS provider disabled β€” the hook handles delivery.
  2. Auth β†’ Hooks β†’ Send SMS Hook:
    • Hook type: HTTPS
    • URL: https://<ref>.supabase.co/functions/v1/send-sms-hook
    • Secret: paste the same value from step 2 above.
  3. Save.

6. Apply the cost-ledger migration

The hook records $0.014 per call in cost_ledger. The constraint that allows operation_type = 'voice-otp-call' ships in supabase/migrations/20260514_106_voice_otp_cost_ledger.sql. Apply it with the rest of the migration set:

Terminal window
supabase db push

7. Verify end-to-end

// In a browser console on https://theaccessible.org/auth/login
await supabase.auth.signInWithOtp({ phone: '+1<your-number>' });

You should receive a call within ~5 seconds that reads each digit slowly, pauses, and repeats the code once. Enter the digits in the β€œ6-digit code” field and you’ll land on /dashboard.


Cost model

ItemUnit costNotes
Outbound voice call (US)$0.014/minTwilio Programmable Voice list price
Polly TTS (US English)includedBundled in Twilio Voice
Phone number rental~$1.15/moPer local US number

Every placed call inserts a row into public.cost_ledger with operation_type = 'voice-otp-call', model = 'twilio-voice', estimated_cost_usd = 0.014, plus the Twilio Call SID and the last 4 digits of the destination phone in metadata.

Troubleshooting

Call never arrives

  • Confirm the number you typed is in E.164 (+ + country code + national).
  • Check Twilio Console β†’ Monitor β†’ Logs β†’ Calls. A failed call there means your TWILIO_VOICE_NUMBER lacks Voice capability or your destination carrier blocked it.
  • Loki / Grafana: filter service="send-sms-hook" to see the request ID and Twilio response code.

Call is flagged as β€œSpam Likely” on the recipient’s caller ID

  • Register your Twilio number with the major US carriers’ caller-name databases via Twilio β†’ Trust Hub β†’ CNAM.
  • For high volume, complete STIR/SHAKEN attestation in Twilio Trust Hub.

Hook returns 401

  • The Standard Webhooks signature didn’t verify. Either the SEND_SMS_HOOK_SECRET set on the Edge Function and the Dashboard’s hook secret diverge, or the webhook-timestamp is more than 5 minutes off (this hook rejects stale timestamps to limit replay risk). Re-paste both values; they must be byte-for-byte identical and start with v1,whsec_.

Hook returns 500 β€œVoice provider misconfigured”

  • One of TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_VOICE_NUMBER is missing in the Edge Function secret store. Re-run supabase secrets set and redeploy.

cost_ledger row missing

  • The migration 20260514_106_voice_otp_cost_ledger.sql was not applied β€” the constraint will reject the insert. Run supabase db push.

Security notes

  • The hook validates the inbound Standard Webhooks signature (verifyStandardWebhook in lib.ts) with HMAC-SHA256 plus a 5-minute timestamp tolerance to limit replay risk. Signature comparison is constant-time.
  • The Service Role key is only used for the best-effort cost_ledger insert. A failed insert is logged and swallowed β€” it never blocks the user’s sign-in.
  • Only the last 4 digits of the destination phone are logged. Full numbers remain inside Twilio and auth.users.