|
I'm looking into implementing webhooks with supabase to respond to database updates. But to do this securely, we need to be able to be sure that the request is coming from Supabase, not an impersonator - and that the payload is accurate. Otherwise we have to blindly do whatever we're told by a random request that comes in. Usually this is implemented with some kind of signature header. e.g. Stripe, Twilio - and Persona have a very simple sample implementation that doesn't require any library/SDK. It looks like it'd be possible to use pgcrypto for this. Note: I see that there's a headers section, but pasting in a static secret value there seems not great - at the very least there should be some docs around it. Answer from Supabase support to the above question:
But if anyone has used pgsodium in this way before, or has found some other way to add authentication to webhook requests, it'd be great to see the implementation. Or, this discussion could become a feature request - since webhooks are already providing a convenience wrapper for pg_net, maybe supabase could use pgsodium on the user's behalf and add a |
Replies: 12 comments 36 replies
|
I have the same question, how do we validate that the request is coming from supabase itself? |
|
@mmkal , did you ever find a solution? Authentication was the first question that popped in mind when I read about webhooks and I've been pretty confounded that I can't find anything. I've been thinking about including an encrypted key on the table itself that I could then verify in my edge function but I'm not sure the webhook would send the decrypted text in the payload. |
|
I ran into that issue myself, so I created a little video to explain how I solved this: https://www.youtube.com/watch?v=iyTfdYVIQiE. You can find the associated article here as well: https://valentinprugnaud.dev/articles/setup-webhook-signature-with-supabase |
|
That looks like the easiest option but how the hell is it safe to do that in a public schema? It ends up as as a trigger on the table: create trigger my_webhook
after insert on my_table for each row
execute function supabase_functions.http_request (
'https://xxx.supabase.co/functions/v1/my-edge-func',
'POST',
'{"Content-type":"application/json","Authorization":"Bearer eyJhbxxxxxxxxxxx"}',
'{}',
'5000'
);Do users have no way of inspecting the schema authenticated with the anon key? |
|
Hey all, Thanks for the candid feedback and constructive approaches. The feedback has been passed on to the team for @supabase/edge-functions and they are looking at ways to address this! |
|
I have been thinking about this the last few days, and one extra possible solution, and possibly nicer than using the service_role key, would be to setup the webhook with the same auth header as the current postgREST session has, if the trigger was executed due to a postgREST call. The trigger should be happening in the same transaction/session as the postgREST call, which would mean that current_setting('request.headers') will be set to a json of all headers. So the code below will retrieve the current Authorization header into the variable So basically the webhook would be called somewhat like 'security invoker' rules inside postgres would call a function, with the JWT of the request triggering the original query that triggered the webhook to run. I must admit, I did not test this, but as long as the trigger originated from a PostgREST initiated session, I do not see why this would not be the case? I will do some tests and report back if someone is interested. I am also working on a nicer way to "handle" webhooks in general on our implementations, will share this in the coming days once finished with the community. |
|
I'm trying this approach, which I've copied from my comment on https://github.com/orgs/supabase/discussions/12813#discussioncomment-10624304; it implements signatures like the custom auth hooks. -- Thanks, this was a great start. I saw that the custom auth hook uses the webhook standard which makes the verification code in the hook very simple and also protects against message modification (content is signed) and replay (to some extent) via timestamps. Using First we can get a shared secret using select encode(pgsodium.crypto_auth_hmacsha256_keygen(), 'base64');Then we can store that secret value in vault, along with the project URL (I've put these both in my seed, and populated them separately in production, so that I can use this in local dev). select vault.create_secret(
-- not my secret for anything, just generated for example
'qHUp608CcaSPRRZVUNbf6cke3M2PgRZkrCWg3UPeNMY=',
'my_function_secret',
'shared secret for my-function'
);
select vault.create_secret(
'http://host.docker.internal:54321',
'project_url',
'supabase project url'
);Then we can create a trigger like this to e.g. push through inserts. create or replace function public.call_edge_function_from_trigger()
returns trigger
set search_path = ''
as $$
declare
function_name text;
secret_name text;
project_url text;
full_url text;
webhook_timestamp text;
webhook_payload jsonb;
begin
function_name := tg_argv[0];
secret_name := tg_argv[1];
select floor(extract(epoch from now()))::bigint::text into webhook_timestamp;
project_url := private.get_secret('project_url');
full_url := project_url || '/functions/v1/' || function_name;
webhook_payload := jsonb_build_object(
'type', tg_op,
'table', tg_table_name,
'schema', tg_table_schema,
'record', new,
'old_record', old
);
-- call the http_request function with the constructed url
perform net.http_post(
url := full_url,
headers := jsonb_build_object(
'content-type', 'application/json',
'webhook-id', function_name,
'webhook-timestamp', webhook_timestamp,
'webhook-signature', 'v1,' || encode(
pgsodium.crypto_auth_hmacsha256(
(function_name || '.' || webhook_timestamp || '.' || webhook_payload::text)::bytea,
decode(private.get_secret(secret_name), 'base64')
),
'base64'
)
),
body := webhook_payload,
timeout_milliseconds := 5000
);
return null;
end;
$$ language plpgsql;
drop trigger if exists send_board_match_notifications on public.board;
create trigger send_board_match_notifications
after insert on public.mytable
for each row
execute function public.call_edge_function_from_trigger(
'my-function',
'my_function_secret'
);And on the Deno edge function side (presumably also usable in some form elsewhere too) we have something like this to verify it, assuming that the same base4 encoded secret has been saved in a function secret import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0'
import { createClient } from '@supabase/supabase-js'
import "jsr:@supabase/functions-js/edge-runtime.d.ts"
import { ok, serverError } from '../_shared/responses.ts'
console.log("Hello from Functions!")
Deno.serve(async (req) => {
const payload = await req.text()
const base64_secret = Deno.env.get('MY_FUNCTION_SECRET_SECRET') ?? ''
const headers = Object.fromEntries(req.headers)
console.log(headers)
const wh = new Webhook(base64_secret)
try {
const verified = wh.verify(payload, headers)
console.log(verified)
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
)
return ok({ message: 'ok' })
} catch (error) {
console.error('Unhandled error', error)
return serverError({
error: `Failed to process the request: ${error}`,
})
}
})Implementation details taken from the JS |
|
I have integrated Supabase with Clerk and set up webhooks for user events like user.created and user.updated. While other CRUD operations are functioning correctly with the Clerk token, I am encountering an issue during webhook events where the Clerk token is not being sent. For existing users, everything works perfectly, and the token is received. However, during the webhook events, I receive the following error from Supabase:
This error seems to be related to the missing Clerk token during the webhook flow, despite the route being set up correctly. The issue only arises with webhook events, not with other operations. Here is the relevant code snippet for creating the Supabase client: route.js for webhook event capturing In the webhook setup, I correctly verify the event and headers, but the missing Clerk token is causing the JWT error. Could you help investigate why the token is not being sent during webhook events and how to resolve this? Here's the complete code for better context: server action : Please can anyone help ? |
|
I faced this issue yesterday as I was trying to create vector embeddings after a table row is updated or created and had a webhook that called an edge function. Based on this tutorial, I added the anon key as Authorization header when setting up the webhook with "Bearer " as the initial in the value of the header. Then it worked fine. I think the docs should be a bit more clearer and updated as I couldn't figure out what the jwt value is until I watched the video, which is the anon key itself. |
|
I'm using Supabase Edge Functions with Stripe Webhooks. Even though I have export const config = { auth: false } at the top of the function, my function always returns 401 Missing Authorization Header. Is it possible that my Supabase project doesn't support auth: false config? |
|
The original answer from foxted was a good starting point back in 2023, but if you're implementing this today I'd look at elyobo's standardwebhooks approach instead. The ChatGPT-generated Deno code in the accepted answer has a subtle issue - it calls req.json() to get the body for verification, then tries to call it again in the handler, which will fail since the body stream is already consumed.
On the DevOps concern several people raised - yeah, the dashboard's "add Authorization header" option bakes the key directly into the trigger definition, which is problematic across environments. Storing secrets in vault and referencing them from a custom trigger function (like ChuckJonas showed) is the way to go if you need reproducible migrations. |
|
The key distinction here is authentication vs integrity. A static Authorization header proves the sender knew a shared token. It does not prove the body was not modified in transit, and it does not give you a clean verification contract on the receiver side. If Supabase wants webhook delivery to feel like Stripe/GitHub/Twilio, the clean shape is:
On the receiver side, one subtle but important point: verification should happen against That is the same failure mode I kept seeing in webhook integrations generally, and it is why I built HookLens for local verification debugging: https://github.com/Ilia01/hooklens |



Not too familiar with Deno (that's why I went with a Nest.js API instead), but a quick Chat-GPT conversation gives me something like this: