paint-brush
How to Receive Webhooks With Supabase Edge Functionsby@tomhacohen
646 reads
646 reads

How to Receive Webhooks With Supabase Edge Functions

by Tom HacohenAugust 10th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Webhooks are how services notify each other of events. Because of how webhooks work, attackers can impersonate services. 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.
featured image - How to Receive Webhooks With Supabase Edge Functions
Tom Hacohen HackerNoon profile picture

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 this:

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 it’s 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 at 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 this:


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 signatures 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/[email protected]/mod.ts";
import { SHA256 } from "https://deno.land/x/[email protected]/deps.ts";
import { Buffer } from "https://deno.land/[email protected]/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, let’s 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/[email protected]/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:



Also published here