Developer Tutorials

Phone OTP with Supabase Edge Functions and StartMessaging

Add phone OTP login to a Supabase project using a Deno edge function that proxies the StartMessaging API. Includes function code, RLS rules, and a React client.

24 April 20269 min read

StartMessaging Team

Engineering

Supabase’s built-in phone auth assumes you have Twilio configured, which means DLT paperwork in India and a $1.50 minimum send. With a ~30-line edge function you can swap that for StartMessaging’s DLT-free OTP API and pay Rs 0.25 per send.

Why Not Supabase’s Built-in Phone Auth

Supabase’s phone auth provider list is global by default and every Indian-friendly option (Twilio, Vonage, MessageBird) requires DLT principal entity registration plus template approvals. That’s 2–6 weeks of paperwork before your first OTP. Wrapping StartMessaging in an edge function gets you live on the same day.

Edge Function: Send OTP

// supabase/functions/send-otp/index.ts
import { serve } from "https://deno.land/std@0.215.0/http/server.ts";

const KEY = Deno.env.get("STARTMESSAGING_API_KEY")!;

serve(async (req) => {
  const { phone } = await req.json();
  if (!/^\+91\d{10}$/.test(phone)) {
    return new Response(JSON.stringify({ error: "invalid phone" }), { status: 400 });
  }

  const res = await fetch("https://api.startmessaging.com/otp/send", {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-Key": KEY },
    body: JSON.stringify({ phoneNumber: phone, idempotencyKey: crypto.randomUUID() }),
  });
  const json = await res.json();
  if (!res.ok) {
    return new Response(JSON.stringify({ error: json.message }), { status: 502 });
  }
  return new Response(JSON.stringify({ requestId: json.data.requestId }), {
    headers: { "Content-Type": "application/json" },
  });
});

Edge Function: Verify OTP

// supabase/functions/verify-otp/index.ts
import { serve } from "https://deno.land/std@0.215.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const KEY = Deno.env.get("STARTMESSAGING_API_KEY")!;
const SUPA = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);

serve(async (req) => {
  const { requestId, code, phone } = await req.json();

  const res = await fetch("https://api.startmessaging.com/otp/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-Key": KEY },
    body: JSON.stringify({ requestId, otpCode: code }),
  });
  const json = await res.json();
  if (!res.ok || !json.data?.verified) {
    return new Response(JSON.stringify({ verified: false }), { status: 401 });
  }

  // Upsert the user, mark phone verified
  const { data: user, error } = await SUPA.auth.admin.createUser({
    phone,
    phone_confirm: true,
  });
  if (error && !error.message.includes("already")) {
    return new Response(JSON.stringify({ error: error.message }), { status: 500 });
  }
  return new Response(JSON.stringify({ verified: true, user }));
});

React Client

const { data: send } = await supabase.functions.invoke("send-otp", { body: { phone } });
// later, after the user enters the code
const { data: verified } = await supabase.functions.invoke("verify-otp", {
  body: { requestId: send.requestId, code, phone },
});

RLS Rules After Verification

Once the user is created with phone_confirm: true, they receive a normal Supabase JWT and your existing RLS policies based on auth.uid() work unchanged. There’s no need to bend your policies for the OTP flow.

Best Practices

  1. Set STARTMESSAGING_API_KEY as a function secret, not a project env var.
  2. Rate-limit by IP at the edge using a counter table or upstash redis.
  3. Never return the OTP code in the function response body.
  4. Log only request IDs, never phone numbers in plain text.

FAQ

Want to compare with a Next.js implementation? See our Next.js App Router OTP guide.

Ready to Send OTPs?

Integrate StartMessaging in 5 minutes. No DLT registration required.