All posts

Setup Webhook Signature With Supabase

4 min read

Supabase Database Webhook is a great way to trigger a function when a database event occurs. However, it is important to secure the webhook endpoint to prevent abuse.

What is a webhook signature?

A webhook signature is a way to verify that the webhook request is coming from Supabase. It is a signature that is generated using a secret key and the request body. The signature is then sent along with the request as a header. The receiver can then verify the signature using the secret key and the request body.

How to setup webhook signature with Supabase?

1. Generate a secret key

First, we'll need a secret key to use to sign the webhook request. We can generate a secret key using uuid generator for example.

489afdea-a951-4202-bdac-b9402b28af20

2. Create secrets in Supabase Vault

Next, we'll create two secrets in Supabase Vault. One for the secret key and another for the URL of our webhook endpoint (to allow for different secrets/endpoints per environments):

-- You can use the following SQL to create the secrets
SELECT vault.create_secret('https://myendpoint.com/supabase/events', 'WEBHOOK_URL', 'Webhook endpoint URL');
 
SELECT vault.create_secret('489afdea-a951-4202-bdac-b9402b28af20', 'WEBHOOK_SECRET', 'Webhook secret key');

3. Create a function to generate the signature

Next, we'll create a function to generate the signature. This function will take the request body as an argument, generate a signature using the secret key and encode using base64:

CREATE OR REPLACE FUNCTION generate_hmac(secret_key text, message text)
    RETURNS text
    LANGUAGE plpgsql
AS
$$
DECLARE
    hmac_result bytea;
BEGIN
    hmac_result := hmac(message::bytea, secret_key::bytea, 'sha256');
    RETURN encode(hmac_result, 'base64');
END;
$$;

4. Create a function to send the webhook with the signature

Once we have our function to generate signature, we can create another function to send the webhook request with the signature:

CREATE OR REPLACE FUNCTION public.webhook()
    RETURNS trigger
    SET search_path = public
    SECURITY DEFINER
    LANGUAGE 'plpgsql'
AS
$$
DECLARE
    url text;
    secret text;
    payload jsonb;
    request_id bigint;
    signature text;
BEGIN
    -- Get the webhook URL and secret from the vault
    SELECT decrypted_secret INTO url FROM vault.decrypted_secrets WHERE name = 'WEBHOOK_URL' LIMIT 1;
    SELECT decrypted_secret INTO secret FROM vault.decrypted_secrets WHERE name = 'WEBHOOK_SECRET' LIMIT 1;
 
    -- Generate the payload
    payload = jsonb_build_object(
            'old_record', old,
            'record', new,
            'type', tg_op,
            'table', tg_table_name,
            'schema', tg_table_schema
              );
 
    -- Generate the signature
    signature = generate_hmac(secret, 'message');
 
    -- Send the webhook request
    SELECT http_post
    INTO request_id
    FROM
        net.http_post(
                url,
                payload,
                '{}',
                jsonb_build_object(
                        'Content-Type', 'application/json',
                        'X-Supabase-Signature', signature
                ),
                '1000'
        );
 
    -- Insert the request ID into the Supabase hooks table
    INSERT INTO supabase_functions.hooks
        (hook_table_id, hook_name, request_id)
    VALUES (tg_relid, tg_name, request_id);
 
    RETURN new;
END;
$$;

5. Create a database trigger

Finally, we can create a database trigger to call our webhook function when a database event occurs. In the next example, we'll create a trigger to call our webhook function when a new row is inserted into the todos table:

CREATE OR REPLACE TRIGGER todos_webhook
    AFTER INSERT
    ON public.todos
    FOR EACH ROW
EXECUTE FUNCTION webhook();

Now, every time a new row is inserted into the todos table, our webhook function will be called and the webhook request will be sent with the signature.

6. Verify the signature in your webhook endpoint

Finally, we can verify the signature in our webhook endpoint. We can do this by comparing the signature in the X-Supabase-Signature header with the signature generated using the secret key and the request body. In this example, we are using Nest.js and a guard, but you can use any framework you like:

supabase-event-handler-header.guard.ts
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  Logger,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Observable } from "rxjs";
import * as crypto from "crypto";
import { Request } from "express";
 
@Injectable()
export class SupabaseEventHandlerHeaderGuard implements CanActivate {
  private readonly logger = new Logger(SupabaseEventHandlerHeaderGuard.name);
 
  constructor(@Inject(ConfigService) private readonly config: ConfigService) {}
 
  canActivate(
    context: ExecutionContext
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest<Request>();
    const signature = request.headers["x-supabase-signature"];
    const body = request?.rawBody;
 
    const decodedSignature = Buffer.from(signature, "base64");
    const calculatedSignature = crypto
      .createHmac("sha256", this.config.get<string>("WEBHOOK_SECRET_KEY"))
      .update(body)
      .digest();
 
    const hmacMatch = crypto.timingSafeEqual(
      decodedSignature,
      calculatedSignature
    );
 
    if (!hmacMatch) {
      this.logger.warn("Request could not be authentified.");
      return false;
    }
 
    return true;
  }
}

That's it! Now we have secure webhooks with Supabase.


Foxy seeing you here! Let's chat!
Logo