Send Supabase Auth SMS codes through TextKit
Supabase Auth can hand every text it would send to a hook of yours. Point its Send SMS Hook at an Edge Function and Supabase still creates and checks the one-time code; your function receives the phone number and the code, checks that the request really came from Supabase, and asks TextKit to text it. Your app keeps calling supabase.auth as before, and every code appears in your TextKit logs with its delivery status.
Before you start
- A Supabase project that uses phone sign-in, and the Supabase CLI linked to it. The Send SMS Hook is available on the Free and Pro plans.
- A TextKit test key (
ssms_sk_test_...). Sign up and copy one from the console. Make it a restricted key with only themessages:sendscope (scopes): the key sits in another service's settings, and a key that leaks can then only send texts.
1. Write the function
Create it with supabase functions new send-sms, then replace supabase/functions/send-sms/index.ts:
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0';
import { TextKit } from 'npm:jointextkit';
// Supabase gives the whole hook 5 seconds, its own retries included:
// no SDK retries and a short timeout keep the function inside that budget.
const textkit = new TextKit(Deno.env.get('TEXTKIT_API_KEY'), { maxRetries: 0, timeout: 3000 });
const hookSecret = Deno.env.get('SEND_SMS_HOOK_SECRETS')!.replace('v1,whsec_', '');
// Every response, errors included, must be JSON, or Supabase Auth answers 500.
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
type Hook = { user: { phone: string }; sms: { otp: string } };
Deno.serve(async (req) => {
const payload = await req.text();
let hook: Hook;
try {
hook = new Webhook(hookSecret).verify(payload, Object.fromEntries(req.headers)) as Hook;
} catch {
return json({ error: { http_code: 401, message: 'Invalid signature' } }, 401);
}
try {
await textkit.messages.send({
to: hook.user.phone,
body: `Your Acme code is ${hook.sms.otp}`,
category: 'otp',
});
return json({});
} catch (err) {
console.error('TextKit send failed', err);
return json({ error: { http_code: 500, message: 'Could not send the code. Try again.' } }, 500);
}
});The hook's payload carries the number in user.phone and the code in sms.otp. Supabase sends no message text, so the function writes it. Supabase's own samples are not consistent about a leading + on the number; TextKit accepts a US or Canadian number either way, so the function passes it through. Supabase signs each request with the Standard Webhooks scheme, which the standardwebhooks package checks. The secret has the form v1,whsec_<base64>, and the function strips the prefix before use.
TextKit sends one-time codes at any hour, and it decides what is a code from the text's wording, not from category. Digits within a few words of a word that names them ("Your Acme code is 482913") count. A text that does not read as a code is held overnight like any other message (quiet hours). If you edit the template, keep the word "code" next to the digits.
2. Turn on the hook
In the Supabase dashboard, open Authentication > Hooks, add a Send SMS hook of type HTTP, and enter the function's URL, https://<project-ref>.supabase.co/functions/v1/send-sms. Copy the hook's secret, which starts v1,whsec_. Then store both secrets and deploy:
supabase secrets set TEXTKIT_API_KEY=ssms_sk_test_...
supabase secrets set SEND_SMS_HOOK_SECRETS='v1,whsec_...'
supabase functions deploy send-sms --no-verify-jwt--no-verify-jwt is needed because the hook runs before the user has a session: the webhook signature, not a Supabase JWT, proves who is calling. To run everything on your machine instead, add the hook to supabase/config.toml, put the secret in supabase/functions/.env, and serve the function with supabase functions serve send-sms --no-verify-jwt:
[auth.hook.send_sms]
enabled = true
uri = "http://host.docker.internal:54321/functions/v1/send-sms"
secrets = "env(SEND_SMS_HOOK_SECRETS)"3. Test in the sandbox
With the test key in place, call supabase.auth.signInWithOtp({ phone }) from your app and watch the code arrive in TextKit's Logs. Finish the sign-in with supabase.auth.verifyOtp({ phone, token, type: 'sms' }), as with any SMS provider.
- Your own phone. Verify your cell in the console's settings and use it as the number. The text arrives for real and ends with "- via TextKit sandbox". Real sandbox texts are capped at 10 a day.
+15005550006. Always endsdelivered, and nothing reaches a phone. Any other real-looking number is simulated too: the message shows in the console's Logs asTest · simulated.+15005550015. A recipient for whom it is always night. Only a text that reads as a one-time code is delivered, so this is how you check your template's wording.+15005550011. Answers403 recipient_opted_out, which shows you what your users see when a send is refused.
See sandbox and test numbers for every magic number.
What Supabase expects back
- 5 seconds in all. Supabase allows the whole hook invocation 5 seconds, its retries included. That is why the function sets
maxRetries: 0and a 3 second timeout on the SDK. - JSON every time. A response without
Content-Type: application/json, an error included, becomes a 500 for your user. - Success is a 200, 202 or 204. An error is
{ "error": { "http_code": ..., "message": ... } }, and Supabase treats a 400 or 403 as a 500.
Go live
- Swap in a live key (
ssms_sk_live_...) withsupabase secrets set TEXTKIT_API_KEY=ssms_sk_live_.... Keep it restricted tomessages:send. - Have a number that can text anyone: a local number on an approved 10DLC registration, or a verified toll-free number. We file the registration for you: see compliance and registration. A registration for two-factor codes fits a sign-in flow best. With one live number, leave
fromout and TextKit sends from it. Once you have a second, addfrom. - Add credits. Free accounts text only numbers they have verified, and your first credit purchase lifts that (credits).
- Send your code template early. In your first 30 days of live sending, our team reviews each new kind of message before it goes out. Codes from a two-factor registration are exempt. Otherwise, send one code to a real person before launch so the template is approved by the time your users sign in (review of new kinds of message).
TextKit sends to US and Canadian numbers only. A number from anywhere else answers 400 invalid_request, so limit phone sign-up to those two countries.
Sources
Provider steps were checked against these pages on 2026-10-28: