DEV Community

kanta13jp1
kanta13jp1

Posted on

Supabase Edge Functions Advanced — Deno, Webhooks, Scheduled Jobs, and Multi-tenant Patterns

Supabase Edge Functions Advanced — Deno, Webhooks, Scheduled Jobs, and Multi-tenant Patterns

Supabase Edge Functions run on Deno Deploy — a lightweight serverless runtime that's not Node.js. This article goes beyond basic CRUD to cover webhook signature verification, scheduled jobs via pg_cron, and secure multi-tenant data isolation.

Understanding the Deno Environment

The biggest gotcha for developers coming from Node.js is that Edge Functions use Deno, not Node. Key differences:

  • No require() — use ES module import syntax only.
  • Node built-ins like fs and crypto are available with the node: prefix (partially).
  • npm packages are importable directly with the npm: prefix (Deno 1.34+).
  • No persistent filesystem writes — use /tmp for ephemeral data only.
// Importing npm packages directly in Deno
import { Resend } from "npm:resend@3";
import Stripe from "npm:stripe@14";
Enter fullscreen mode Exit fullscreen mode

For local development, run supabase functions serve --env-file .env.local to get a Deno environment identical to production.

Receiving Webhooks with HMAC-SHA256 Verification

Any webhook endpoint that's publicly accessible needs signature verification — without it, anyone can POST fake events. Here's a complete verifier using the Web Crypto API (available natively in Deno):

// supabase/functions/webhook-receiver/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";

const SECRET = Deno.env.get("WEBHOOK_SECRET")!;

serve(async (req) => {
  const signature = req.headers.get("x-signature-256") ?? "";
  const body = await req.arrayBuffer();

  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(SECRET),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"]
  );

  const sigBytes = hexToBytes(signature.replace("sha256=", ""));
  const valid = await crypto.subtle.verify("HMAC", key, sigBytes, body);

  if (!valid) {
    return new Response("Unauthorized", { status: 401 });
  }

  const payload = JSON.parse(new TextDecoder().decode(body));
  // Handle your event here
  console.log("Received event:", payload.type);

  return new Response("OK", { status: 200 });
});

function hexToBytes(hex: string): Uint8Array {
  const bytes = new Uint8Array(hex.length / 2);
  for (let i = 0; i < hex.length; i += 2) {
    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
  }
  return bytes;
}
Enter fullscreen mode Exit fullscreen mode

The crypto.subtle API is synchronous-free and runs at near-native speed inside Deno — no external dependencies needed.

Scheduled Jobs with pg_cron + pg_net

Supabase ships with the pg_cron and pg_net extensions, letting you trigger HTTP requests directly from PostgreSQL on a schedule. This is perfect for daily digests, churn detection, and reminder emails.

-- Run an Edge Function every day at 09:00 JST (00:00 UTC)
select cron.schedule(
  'daily-digest-job',
  '0 0 * * *',
  $$
  select net.http_post(
    url := 'https://<project-ref>.supabase.co/functions/v1/daily-digest',
    headers := jsonb_build_object(
      'Authorization', 'Bearer ' || current_setting('app.service_key'),
      'Content-Type', 'application/json'
    ),
    body := '{}'::jsonb
  );
  $$
);
Enter fullscreen mode Exit fullscreen mode

On the Edge Function side, validate the Authorization header against your service key before executing any logic — this prevents external callers from triggering your scheduled job endpoint.

Multi-tenant Patterns: JWT → Tenant ID → RLS

In a multi-tenant SaaS, every database query must be scoped to the calling organization. Using the service role key bypasses Row Level Security (RLS), which is dangerous. Instead, pass the user's JWT through directly — RLS policies enforce the tenant boundary automatically.

// supabase/functions/tenant-api/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";

serve(async (req) => {
  // Extract the user's JWT from the Authorization header
  const authHeader = req.headers.get("Authorization") ?? "";
  const token = authHeader.replace("Bearer ", "");

  // Create a client scoped to this user — RLS applies automatically
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: `Bearer ${token}` } } }
  );

  // Pull tenant_id from custom JWT claims (set via auth hook)
  const { data: { user } } = await supabase.auth.getUser();
  const tenantId = user?.app_metadata?.tenant_id;

  if (!tenantId) {
    return new Response(JSON.stringify({ error: "No tenant context" }), {
      status: 403,
      headers: { "Content-Type": "application/json" },
    });
  }

  // RLS policy filters rows to this tenant automatically
  const { data, error } = await supabase
    .from("projects")
    .select("id, name, created_at");

  if (error) throw error;

  return new Response(JSON.stringify(data), {
    headers: { "Content-Type": "application/json" },
  });
});
Enter fullscreen mode Exit fullscreen mode

Your RLS policy should reference the custom claim:

create policy "tenant isolation"
  on projects
  for all
  using (
    tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid
  );
Enter fullscreen mode Exit fullscreen mode

Managing Secrets with Deno.env

Store production secrets with supabase secrets set, never in source code:

supabase secrets set RESEND_API_KEY=re_xxxx
supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_xxxx
Enter fullscreen mode Exit fullscreen mode

Access them in your function:

const resend = new Resend(Deno.env.get("RESEND_API_KEY")!);
Enter fullscreen mode Exit fullscreen mode

For local development, put secrets in .env.local (already in .gitignore) and reference them with supabase functions serve --env-file .env.local.

Summary

  • Deno runtime: Use import + npm: prefix. No require(), no persistent filesystem.
  • Webhooks: Always verify HMAC-SHA256 signatures before processing events.
  • Scheduled jobs: pg_cron + pg_net let PostgreSQL drive your Edge Functions on a timer.
  • Multi-tenancy: Pass user JWT directly; let RLS enforce the tenant boundary — never bypass with the service role key.

Next up: Indie SaaS retention strategy — churn analysis, email automation, and habit-forming UX.

Top comments (0)