Agent skill
Supabase Webhooks Skill
Receive and verify Supabase webhooks. Use when setting up Supabase Database Webhooks (INSERT, UPDATE, DELETE table events sent via pg_net triggers) or Supabase Auth Hooks (send_email, send_sms, custom_access_token, before_user_created, mfa_verification_attempt, password_verification_attempt), debugging Standard Webhooks signature verification with the webhook-id, webhook-timestamp and webhook-signature headers, or handling the `v1,whsec_` secret format.
Install this skill
npx skills add hookdeck/webhook-skills --skill supabase-webhooks
When to Use This Skill
- How do I receive Supabase Database Webhooks (INSERT / UPDATE / DELETE)?
- How do I verify a Supabase Auth Hook signature?
- Why is my Supabase
webhook-signatureverification failing? - How do I secure a Supabase Database Webhook when there is no signature?
- How do I implement a
send_email/send_sms/custom_access_tokenAuth Hook? - What does the
v1,whsec_secret prefix mean?
Two Surfaces, Two Security Models
Supabase sends outbound HTTP from two different systems. They do not share a security model — do not apply one's verification to the other.
| Database Webhooks | Auth Hooks (HTTP Hook) | |
|---|---|---|
| Source | Postgres trigger → pg_net | Supabase Auth (GoTrue) |
| Docs | Database Webhooks | Auth Hooks |
| Events | INSERT, UPDATE, DELETE | 6 auth lifecycle hooks |
| Signature | None — no HMAC, no signing secret, no Supabase header | Standard Webhooks HMAC-SHA256 |
| Auth | Whatever headers you configure (e.g. Authorization: Bearer …) | webhook-id / webhook-timestamp / webhook-signature |
| Semantics | Fire-and-forget, async | Request/response — your JSON body changes auth behaviour |
| Retries | None documented | Up to 3 retries (2s backoff, 5s total budget) — requires a non-empty retry-after header |
Supabase documents no source-IP allowlist and no user-agent value for either surface. Do not build either into your receiver.
Verification (core)
Auth Hooks — Standard Webhooks HMAC-SHA256
const { Webhook } = require('standardwebhooks');
// Secret is issued as "v1,whsec_<base64>". Strip the "v1,whsec_" prefix; the
// remainder is STANDARD base64 that the library base64-DECODES to the raw HMAC
// key. Using the base64 string itself as the key rejects every real delivery.
const wh = new Webhook(process.env.SUPABASE_AUTH_HOOK_SECRET.replace('v1,whsec_', ''));
// Signs `{webhook-id}.{webhook-timestamp}.{raw_body}` and base64-compares in
// constant time against every space-delimited `v1,<sig>` entry, with a
// ±5-minute timestamp tolerance. Pass the RAW body — re-serialised JSON fails.
const payload = wh.verify(rawBody, {
'webhook-id': headers['webhook-id'],
'webhook-timestamp': headers['webhook-timestamp'],
'webhook-signature': headers['webhook-signature'],
}); // throws WebhookVerificationError on failure
Database Webhooks — developer-configured shared secret
There is no signature to verify. Authenticate with a header you set yourself when creating the webhook, compared in constant time:
const crypto = require('crypto');
function timingSafeEqualStr(a, b) {
const x = Buffer.from(a || '', 'utf8');
const y = Buffer.from(b || '', 'utf8');
if (x.length !== y.length) return false; // length is not secret here
return crypto.timingSafeEqual(x, y);
}
// Header value comes from the headers JSON you pass to supabase_functions.http_request
function authenticateDatabaseWebhook(headers, secret) {
if (!secret) return false;
const authorization = headers['authorization'] || '';
const presented = authorization.toLowerCase().startsWith('bearer ')
? authorization.slice(7).trim()
: headers['x-webhook-secret'] || '';
return timingSafeEqualStr(presented, secret);
}
if (!authenticateDatabaseWebhook(req.headers, process.env.SUPABASE_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Unauthorized' });
}
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Database Webhook Events
Only three, all fired after the row change:
type | Fires when | record | old_record |
|---|---|---|---|
INSERT | A row is inserted | new row | null |
UPDATE | A row is updated | new row | previous row |
DELETE | A row is deleted | null | deleted row |
type is UPPERCASE and is the discriminator. The full payload has exactly four other top-level fields — there are no others:
{ "type": "INSERT", "table": "<table name>", "schema": "<schema name>", "record": { }, "old_record": null }
record / old_record mirror the table's own columns, so their inner shape is whatever your table defines.
Create one in the Dashboard (Integrations → Webhooks) or in SQL:
create trigger "my_webhook" after insert on "public"."my_table"
for each row execute function "supabase_functions"."http_request"(
'https://example.com/webhooks/supabase', -- url
'POST', -- method (POST or GET)
'{"Content-Type":"application/json","Authorization":"Bearer YOUR_SHARED_SECRET"}',
'{}', -- params
'1000' -- timeout in ms
);
There is no delivery id header and no documented retry policy — pg_net is fire-and-forget within timeout_ms. Delivery history lives in the database's net schema. Idempotency is your receiver's job: dedupe on a primary key inside record / old_record.
Auth Hooks
Six hooks, config keys exactly as documented:
| Hook | Plans | Request payload | Your response |
|---|---|---|---|
before_user_created | Free, Pro | { metadata: { uuid, time, name, ip_address }, user } | {} to allow; { "error": { "http_code": 400, "message": "…" } } to reject |
custom_access_token | Free, Pro | { user_id, claims, authentication_method } | { claims: { … } } to write into the JWT |
send_sms | Free, Pro | { user, sms: { otp } } | {} — you send the SMS |
send_email | Free, Pro | { user, email_data: { token, token_hash, redirect_to, email_action_type, site_url, token_new, token_hash_new, old_email, old_phone, provider, factor_type } } | {} — you send the email |
mfa_verification_attempt | Teams, Enterprise | { factor_id, user_id, valid } | { decision: "continue" | "reject", message } |
password_verification_attempt | Teams, Enterprise | { user_id, valid } | { decision: "continue" | "reject", message, should_logout_user } |
Auth Hooks are request/response, not fire-and-forget. The auth flow blocks on your reply and your JSON body changes what Supabase does. Errors are any status >= 400; a 429 or 503 is retried up to three times with a two-second backoff only if you also send a non-empty retry-after header (e.g. retry-after: true), inside a 5-second total budget for the whole invocation. Keep the handler fast and push slow work out of band. Always send Content-Type: application/json; 204 is rejected by custom_access_token, mfa_verification_attempt and password_verification_attempt, and 400 / 403 are turned into a 500 returned to your application.
Auth Hooks can alternatively be configured as a Postgres function (pg-functions://postgres/<schema>/<fn>), in which case no HTTP request leaves the instance and none of the above applies. This skill covers the HTTP variant.
Environment Variables
# Auth Hooks — the secret Supabase issues, including the "v1,whsec_" prefix
SUPABASE_AUTH_HOOK_SECRET=v1,whsec_UkxKUzBrOWt2c1hHTDF3YjNVSHhOZmw3Y0dyNXlKRHE=
# Database Webhooks — a shared secret YOU choose and put in the trigger's
# headers JSON. Supabase does not generate or sign anything here.
SUPABASE_WEBHOOK_SECRET=a-long-random-string-you-generate
Supabase's own config key for a hook secret is plural (e.g. SEND_SMS_HOOK_SECRETS) because multiple pipe-delimited secrets are planned for rotation. The webhook-signature header is already a space-delimited list of v1,<sig> entries for exactly that reason — accept if any entry matches.
Local Development
--path replaces the forwarded request path, so run one tunnel per surface:
# No install, no account required — creates a guest account on first run
# Database Webhooks (Express/Next.js on 3000; use 8000 for FastAPI)
npx hookdeck-cli listen 3000 supabase --path /webhooks/supabase
# Auth Hooks — a separate source, because --path replaces the request path
npx hookdeck-cli listen 3000 supabase-auth-hook --path /webhooks/supabase/auth-hook
Paste the first tunnel URL into the Dashboard (Integrations → Webhooks) and the second as the Auth Hook URI (Authentication → Hooks). One tunnel for both would route Auth Hooks into the Database Webhook handler and get a 401.
Reference Materials
- references/overview.md - Both surfaces, events, payload shapes
- references/setup.md - Dashboard and SQL configuration, getting secrets
- references/verification.md - Signature verification details and gotchas