I have recently been using Supabase as a backend for a mobile application project. One of the key features of the project is SMS based authentication using one-time-passwords (OTP).
I decided to use Bird (formerly known as MessageBird) as my phone/sms provider. When trying to trigger an OTP code using the Supabase SDK (via Supabase auth), I kept getting the following error:
Request not allowed (incorrect access_key)
This error only occurred when trying to trigger the SMS via Supabase. Using the same access key, I was able to trigger an SMS successfully via curl
.
Per Supabase documentation, MessageBird is supported out of the box as an SMS OTP auth provider, however, I learned that this is no longer true. Apparently the Supabase integration to MessageBird is broken due to a change in MessageBird APIs, as indicated by the following comment in a related GitHub issue:
https://github.com/supabase/auth/issues/1554#issuecomment-2292359837
The workaround is to use a new feature called Auth Hooks. These are basically webhooks that Supabase will call to offload the responsibility of actually sending the message. The documentation for Auth Hooks is here: https://supabase.com/docs/guides/auth/auth-hooks?queryGroups=language&language=http
The documentation is decent but leaves a lot to be desired. In any case, I was able to figure out how to implement the Auth Hook specifically with MessageBird.
In general, we need to do 2 things:
Define a Supabase edge function that will be responsible for handling the webhook request and sending the SMS via MessageBird
Configure Supabase to call this edge function when sending an SMS
Step 1: Defining the Edge Function
Create a new edge function using the Supabase CLI:
supabase function new send-sms
Adjust the function configuration in
config.toml
file to setverify_jwt = false
Step 2: Generating the Webhook Secret
It is important that the inbound webhook call is validated to ensure it was actually sent by Supabase and not some unknown sender. In order to validate the function, we first need to generate a secret value:
Create a new environment variable in the
.env
file calledSEND_SMS_HOOK_SECRETS
(you can call it whatever you want)The value of the secret needs to follow the format
v1,whsec_<base64-secret>
where the<base64-secret>
is replaced with a randomly generated valueOn
mac
use the terminal to run the following commandopenssl rand -base64 32
to generate a value for the<base64-secret>
For example, if running the openssl
command returns sqWBTgCp4R1X5O4NH6mBS5mU9U3MwMef2fOfBJ/ezEg=
the resulting value in the .env
file would be:
SEND_SMS_HOOK_SECRETS=v1,whsec_sqWBTgCp4R1X5O4NH6mBS5mU9U3MwMef2fOfBJ/ezEg=
Step 3: Validating the Webhook
In order to validate the webhook, we can use the standard-webhooks library. In the Supabase edge function, add the following code:
Deno.serve(async (req) => { try { const payload = await req.text(); const hookSecrets = Deno.env.get("SEND_SMS_HOOK_SECRETS"); if (!hookSecrets) { return new Response("Unauthorized", { status: 401 }); } const secretKey = hookSecrets.replace("v1,whsec_", ""); // Extract headers and security specific fields const headers = Object.fromEntries(req.headers); const wh = new Webhook(secretKey); const { user, sms } = wh.verify( payload, headers, ) as SmsWebhookPayload; });
The return value of wh.verify(…)
is a JSON object that has user
and sms
properties. We only need two values from this payload, the phone number and the otp code.
Refer to the Supabase documentation for an example of the full JSON payload: https://supabase.com/docs/guides/auth/auth-hooks/send-sms-hook?queryGroups=language&language=http
Note: The Supabase payload example suggests the the phone number returned in user.phone
contains a correctly formatted international phone number including the country code. I noticed that this was not the case. Even though I was sending the phone number properly formatted, the payload received by the webhook endpoint was missing the “+” character which lead to an error. This is easily corrected by adding this to the number prior to make the API request to MessageBird.
Here is a simple TypeScript interface we can use to strongly type the return value of the wh.verify(…)
function:
interface SmsWebhookPayload { user: { phone: string; }; sms: { otp: string; }; }
Sending the SMS via MessageBird
Now we just need to send the SMS message via MessageBird. In order to do so, we first need to define 3 environment variables that will be necessary to send the request:
SMS_WORKSPACE_ID: The id of the MessageBird workspace
SMS_CHANNEL_ID: The id of the SMS channel configured on MessageBird
SMS_ACCESS_KEY: The MessageBird access key
Add these three variables into the the .env
file.
Note: A simple way to find some of these values is to go to the MessageBird dashboard under the Developer
section and view the Send your first SMS
step in the Get started with SMS API
section. There is an example curl
command which contains the Workspace Id and Channel Id.
Once the environment variables have been configured, implement the following sendSms function in the edge function:
const sendSms = async ( phoneNumber: string, messageBody: string, ): Promise<MessageBirdResponse> => { const workspaceId = Deno.env.get("SMS_WORKSPACE_ID"); const channelId = Deno.env.get("SMS_CHANNEL_ID"); const accessKey = Deno.env.get("SMS_ACCESS_KEY"); if (!workspaceId || !channelId || !accessKey) { throw new Error( "Missing required environment variables: SMS_WORKSPACE_ID, SMS_CHANNEL_ID, or SMS_ACCESS_KEY", ); } const response = await fetch( `https://api.bird.com/workspaces/${workspaceId}/channels/${channelId}/messages`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `AccessKey ${accessKey}`, }, body: JSON.stringify({ receiver: { contacts: [{ identifierValue: `+${phoneNumber}`, identifierKey: "phonenumber", }], }, body: { type: "text", text: { text: messageBody, }, }, }), }, ); if (!response.ok) { const errorText = await response.text(); throw new Error( `Failed to send SMS: ${response.status} ${response.statusText} - ${errorText}`, ); } const data: MessageBirdResponse = await response.json(); if (data.status !== "accepted") { throw new Error( `SMS not accepted by MessageBird: ${data.status} - ${data.reason}`, ); } return data; };
Below is a TypeScript interface that can be used for the response from MessageBird:
interface MessageBirdResponse { id: string; channelId: string; sender: { connector: { id: string; identifierValue: string; }; }; receiver: { contacts: Array<{ id: string; identifierKey: string; identifierValue: string; platformAddressSelector: string; platformAddress: string; annotations: { name: string; }; countryCode: string; }>; }; body: { type: string; text: { text: string; }; }; status: string; validity: number; reason: string; direction: string; lastStatusAt: string; createdAt: string; updatedAt: string; }
Putting it all together
Finally, we can simply call the sendSms function with the otp code and phone number extracted from the webhook payload:
Deno.serve(async (req) => { try { const payload = await req.text(); const hookSecrets = Deno.env.get("SEND_SMS_HOOK_SECRETS"); if (!hookSecrets) { return new Response("Unauthorized", { status: 401 }); } const secretKey = hookSecrets.replace("v1,whsec_", ""); // Extract headers and security specific fields const headers = Object.fromEntries(req.headers); const wh = new Webhook(secretKey); const { user, sms } = wh.verify( payload, headers, ) as SmsWebhookPayload; const messageBody = `Your login code is: ${sms.otp}`; // Send the SMS await sendSms(user.phone, messageBody); });
Note: I omitted error handling here to keep the code snippets brief. In a production scenario, remember to properly handle errors.
Deploying the changes:
The last step is to deploy the changes to Supabase. The deployment process is as follows:
Update the edge function secrets in the Supabase dashboard to include the values we created earlier
SEND_SMS_HOOK_SECRETS
SMS_WORKSPACE_ID
SMS_CHANNEL_ID
SMS_ACCESS_KEY
Deploy the send-sms edge function we created. If using Supabase CLI:
supabase functions deploy send-sms
Configure the Auth Hook in the Supabase Dashboard:
Go to https://supabase.com/dashboard/project/_/auth/hooks
Add a new hook:
Set the endpoint to be the endpoint of the edge function (can be copied from here: https://supabase.com/dashboard/project/_/functions)
Set the
secret
equal to the value of theSEND_SMS_HOOK_SECRETS
environment variable
And we are Done! Give it a test.
Some closing thoughts:
This isn’t the first time I have encountered poor and outdated documentation with Supabase. For a product targeted at developers, documentation should be a top priority because it directly impacts the developer experience (and therefore the user experience). I hope the Supabase team starts to take documentation quality and thoroughness more seriously and invests more time and resources to it. Right now, too many important details are scattered in various GitHub issues and threads which makes working with the platform painful.