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
- Sign in to https://console.twilio.com/.
- Phone Numbers β Manage β Buy a number β tick Voice capability, pick a local US number (~$1.15/mo).
- 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.
- From the Account Info panel on the Twilio dashboard, copy:
Account SID(starts withACβ¦)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:
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
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
supabase functions deploy send-sms-hook --project-ref <ref>Smoke-test it returns 401 to an unauthenticated request:
curl -sI https://<ref>.supabase.co/functions/v1/send-sms-hook# expect: HTTP/2 4015. 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:
- Auth β Providers β Phone β toggle Enable phone provider on. Leave the built-in Twilio SMS provider disabled β the hook handles delivery.
- 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.
- Hook type:
- 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:
supabase db push7. Verify end-to-end
// In a browser console on https://theaccessible.org/auth/loginawait 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
| Item | Unit cost | Notes |
|---|---|---|
| Outbound voice call (US) | $0.014/min | Twilio Programmable Voice list price |
| Polly TTS (US English) | included | Bundled in Twilio Voice |
| Phone number rental | ~$1.15/mo | Per 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_NUMBERlacks 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_SECRETset on the Edge Function and the Dashboardβs hook secret diverge, or thewebhook-timestampis 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 withv1,whsec_.
Hook returns 500 βVoice provider misconfiguredβ
- One of
TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_VOICE_NUMBERis missing in the Edge Function secret store. Re-runsupabase secrets setand redeploy.
cost_ledger row missing
- The migration
20260514_106_voice_otp_cost_ledger.sqlwas not applied β the constraint will reject the insert. Runsupabase db push.
Security notes
- The hook validates the inbound Standard Webhooks signature
(
verifyStandardWebhookinlib.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_ledgerinsert. 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.