Svix Blog
Published on

Receiving webhooks with Supabase Edge Functions

Authors
  • avatar
    Name
    Ken Ruf
    Twitter

Cover image

Receiving Webhooks 101

Webhooks are how services notify each other of events. They are essentially just a POST request to a preset endpoint. You generally want to use one function per service that will listen to all event types. For example, if you receive webhooks from Stripe, you might structure your URL like:

https://www.example.com/webhooks/stripe

To indicate that a webhook has been successfully processed, return a 2xx (status code 200-299) response within a reasonable time-frame. It's important to turn off CSRF protection for the endpoint if the framework automatically enables it.

Because of how webhooks work, attackers can impersonate services by sending fake webhooks to an endpoint. They're just an HTTP POST from an unknown source so are a potential security hole for many applications, (or at the very least a source of problems). This is why most webhook implementations sign their messages. It lets receivers verify the origin of the message.

In this tutorial we'll set up a Supabase Edge Function to receive a webhook from Svix and validate the webhook signature.

Supabase Setup (note: make sure to enable Deno for the workspace)

Run the following commands in the root of your project:

supabase init

This initializes Supabase in your project.

supabase login

You'll need to create an access token in your Supabase account dashboard.

supabase link --project-ref your-project-ref

Your project ref is available under your project settings. You'll be prompted here for your database password. Linking your project makes it easier to deploy your function without having to enter your project's reference ID over and over.

supabase functions new receive-webhook

This creates the default functions directory and the boilerplate for your function which we'll call “receive-webhook”.

supabase functions deploy receive-webhook --no-verify-jwt

It's important to set the no-verify-jwt flag when deploying your function because it will be a public URL and the sender won't be an authenticated user with a JWT.

We've successfully created the webhook endpoint where we'll receive webhook messages. You can find the URL in your Supabase dashboard under Edge Functions:

Now we're ready to start writing our function for verifying the webhook signature. The specifics will vary from provider to provider but the basic idea is to calculate the expected signature and compare it to the signature provided in the header of the webhook message.

Generally, there will be 3 headers used for verification:

  1. webhook-id: the unique identifier for the message
  2. webhook-timestamp: timestamp in seconds since epoch
  3. webhook-signature: the base64 encoded signature

Verifying Manually

To get started, let's construct the content to be signed. A common signature scheme (and the one we use at Svix) is to concatenate the webhook message's ID, timestamp, and payload body separated by the full-stop character ("."). It will look something like:

const webhook_id = request.headers.get('webhook-id')
const webhook_timestamp = request.headers.get('webhook-timestamp')

const body = await request.text()

const signed_content = `${webhook_id}.${webhook_timestamp}.${body}`

Different providers construct their signature in different ways so make sure to reference their documentation.

Body is the raw body of the request. The signature is sensitive to any changes, so even a small change (including extra or missing whitespace) in the body will cause the signature to be completely different. This is a common failure mode for verifying webhook signatures.

Next we need to determine the expected signature. Most providers use an HMAC with SHA-256 to sign its webhooks. Some will use other hash functions so make sure to reference your webhook provider's documentation.

To calculate the expected signature, you should HMAC the signed content from above using your signing secret as the key.

Get your signing secret from your webhook provider and save it as an environment variable in Supabase. You can do this via the following command using the Supabase CLI:

supabase secrets set WEBHOOK_SECRET=your-webhook-secret

Now calculate the expected signature:

import { HMAC } from "https://deno.land/x/hmac@v2.0.1/mod.ts";
import { SHA256 } from "https://deno.land/x/hmac@v2.0.1/deps.ts";
import { Buffer } from "https://deno.land/std@0.177.0/node/buffer.ts";
import { timingSafeEqual } from "./timing_safe_equal.ts";

const secret = Buffer.from(Deno.env.get("WEBHOOK_SECRET")!.split("_")[1], "base64")

const expected_signature = new HMAC(new SHA256())
   .init(secret, "base64")
   .update(signed_content)
   .digest("base64")

Finally we'll add a try/catch to handle returning the correct response based on whether the signature from the header matches our expected signature:

const signature = request.headers.get("webhook-signature")!.split(",")[1]

try {
    timingSafeEqual(signature, expected_signature)
} catch (err) {
    console.log("Invalid Signature")
    return new Response(err.message, { status: 400 })
}

console.log("Signature Verified")
return new Response(JSON.stringify({ ok: true }), { status: 200 })

Note that we're using a constant time comparison function to check the given signature against the signature we calculated. This is to prevent timing attacks on the signature. For more information about timing attacks, you can read this article on securely verifying signature hashes from our blog.

Verifying Svix Webhooks

For a concrete example, lets verify a webhook from Svix. We provide open source libraries for verifying webhooks sent from Svix or from any Svix user to simplify the verification process.

import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { Webhook } from "https://cdn.skypack.dev/svix"

serve(async (request) => {
  const headers = {
    "svix-id": request.headers.get("svix-id"),
    "svix-signature": request.headers.get("svix-signature"),
    "svix-timestamp": request.headers.get("svix-timestamp")
  }
  const payload = await request.text()
  const secret = Deno.env.get("WEBHOOK_SECRET")!
  const webhook = new Webhook(secret)

  try {
    await webhook.verify(payload, headers)
  } catch (err) {
    console.log("Invalid Signature")
    return new Response(err.message, { status: 400 })
  }

  console.log("Signature Verified")
  return new Response(JSON.stringify({ ok: true }), { status: 200 })
})

We handle extracting content from the message, generating the HMAC, any string manipulations to remove versioning and other prefixes, and the constant time comparison.

If we send a test webhook from Svix, we can see that the message was successful:

We also see in the Supabase functions logs that the signature was verified: