Supabase webhook secret: DB webhooks aren't signed
There is no Supabase Database Webhook secret to find: the requests are unsigned pg_net calls, so generate your own random token, send it as a header, and check it with timingSafeEqual before the handler does anything. Auth Hooks, by contrast, are signed with a v1,whsec_ secret you generate in the dashboard.
Where do I find the Supabase webhook secret?
Nowhere, unless the request comes from an Auth Hook. Two Supabase features that POST to your endpoint get mixed up here, and only one of them signs its requests:
| Database Webhooks | Auth Hooks (HTTP) | |
|---|---|---|
| Fired by | a trigger on INSERT / UPDATE / DELETE | Supabase Auth (Send Email, Custom Access Token, …) |
| Signed? | No | Yes, Standard Webhooks |
| Secret | none; you make one up | v1,whsec_…, generated under Auth Hooks in the dashboard |
| Headers you receive | whatever you configured | webhook-id, webhook-timestamp, webhook-signature |
| How to verify | compare your own token | new Webhook(secret).verify(body, headers) |
The Database Webhooks docs call them "a convenience wrapper around triggers using the pg_net extension" and never mention signing, secrets or authentication. The SQL form on that page calls supabase_functions.http_request, which reads its URL, method and headers from trigger arguments, builds { type, table, schema, record, old_record } and passes it to net.http_post. Its source in Supabase's self-hosting setup has no signing step. The Auth Hooks docs, by contrast, state that "HTTP Hooks in Supabase follow the Standard Webhooks Specification."
The feature request for signed hooks, supabase/supabase#2691 ("Support signed hooks that can be verified to come from a trusted Supabase project"), was opened in August 2021 and closed on 10 August 2025 with "We're going to close this one due to inactivity." GitHub records the close as "completed", but nothing shipped: a commenter replied the same day that the feature had never been implemented.
What a forged event does to an unverified route
/api/webhooks/supabase is a public POST endpoint, and the payload format is in the docs. This is the usual receiver: it validates the shape, then grants welcome credits with the secret key whenever a profile row is inserted.
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import { z } from "zod";
// Server-only admin client: the secret key bypasses RLS.
const admin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!
);
const Payload = z.object({
type: z.enum(["INSERT", "UPDATE", "DELETE"]),
table: z.string(),
schema: z.string(),
record: z.object({ id: z.string().uuid() }).nullable(),
});
// Target of a Database Webhook on INSERT into public.profiles.
export async function POST(request: Request) {
const parsed = Payload.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ error: "bad payload" }, { status: 400 });
}
const { type, table, record } = parsed.data;
if (type === "INSERT" && table === "profiles" && record) {
// New signup: grant 500 welcome credits.
await admin.from("credit_grants").insert({ user_id: record.id, amount: 500 });
}
return NextResponse.json({ ok: true });
}
- Warningapp/api/webhooks/supabase/route.ts:20
Webhook without signature verification
Verify the signature before trusting the payload — Stripe: stripe.webhooks.constructEvent(rawBody, sig, endpointSecret); svix/Clerk: new Webhook(secret).verify(...); generic HMAC: recompute with crypto.createHmac and compare using timingSafeEqual. Read the RAW body (request.text()), not the parsed JSON. Fail closed: reject when the signature/secret header is missing or the env var is unset. If you verify in a shared helper, name it verify*Signature so it is recognised, or exclude the file in .guardlayerignore.
Now send it a row that was never inserted:
curl -X POST https://yourapp.com/api/webhooks/supabase \
-H "Content-Type: application/json" \
-d '{"type":"INSERT","table":"profiles","schema":"public","record":{"id":"<any user id>"}}'
The zod parse passes because the payload is well formed: schema validation checks shape, not origin. The insert runs with a key that bypasses RLS, so no policy stops it, and every repeat grants another 500 credits to whichever account the attacker names, starting with their own. If the handler marks orders paid instead, that's what the forgery does.
Should the webhook send the service_role key in the Authorization header?
Not to your own app. Supabase's API key migration guide says calls made with pg_net, "including Database Webhooks, usually send the service_role key on the Authorization: Bearer header." That only makes sense when the receiver is entitled to hold the key. Point the same webhook at a Next.js route or an n8n workflow and the receiver gets your RLS-bypassing master key on every row event, where request logging or an error tracker can capture it.
Proving a request is genuine needs a shared value that unlocks nothing else:
- Generate one with
openssl rand -hex 32. - Store it in your app's environment as
SUPABASE_WEBHOOK_TOKEN. - Send it from the database as an
x-webhook-tokenheader.
In n8n, the Webhook node's Header auth option can require that header for you. If the target is a Supabase Edge Function, follow the official path instead: the guide's example sends a secret key on the apikey header and reads it from Vault.
Why "store it in Vault" doesn't work for Dashboard webhooks
The same guide warns: "Don't hardcode a secret key in SQL or a webhook configuration, where it's stored in plain text." The stock webhook can't follow that advice. http_request loads its headers with headers = TG_ARGV[2]::jsonb, and Postgres trigger arguments are "literal string constants" (CREATE TRIGGER), so there is no slot for a Vault lookup. Whatever you type into the webhook's headers, your own token included, lives in the trigger definition in plain text, and in your repo too if you write that trigger in a migration.
So replace the Dashboard webhook with a trigger function you own, one that calls net.http_post itself and reads the token at call time:
-- Once, in the SQL editor, not in a migration:
-- select vault.create_secret('<openssl rand -hex 32 output>', 'profiles_webhook_token');
create schema if not exists private;
create or replace function private.notify_profile_created()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
perform net.http_post(
url := 'https://yourapp.com/api/webhooks/supabase',
body := jsonb_build_object(
'type', TG_OP,
'table', TG_TABLE_NAME,
'schema', TG_TABLE_SCHEMA,
'record', to_jsonb(NEW)
),
headers := jsonb_build_object(
'Content-Type', 'application/json',
'x-webhook-token', (
select decrypted_secret from vault.decrypted_secrets
where name = 'profiles_webhook_token'
)
)
);
return NEW;
end;
$$;
create trigger profiles_webhook
after insert on public.profiles
for each row execute function private.notify_profile_created();
Without security definer, a trigger function runs as whichever role inserted the row, often authenticated, and that role has no business reading Vault. With it, the function runs as its owner. Vault's docs warn that "anyone that has access to the view has access to decrypted secrets", so keep the function in a schema the Data API doesn't expose and pin search_path. Then delete the old Dashboard webhook, or every row goes out twice.
The fix in the route
Check the header before you touch the body, and compare in constant time:
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
function hasValidToken(request: Request): boolean {
const expected = process.env.SUPABASE_WEBHOOK_TOKEN;
const received = request.headers.get("x-webhook-token");
if (!expected || !received) return false;
const a = Buffer.from(received);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
export async function POST(request: Request) {
if (!hasValidToken(request)) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// ...parse and handle exactly as before
}
Node's timingSafeEqual throws when the buffers differ in byte length, hence the length check first. A missing SUPABASE_WEBHOOK_TOKEN rejects every request, so a misconfigured deploy fails closed.
A static token proves the sender knows the secret. It doesn't bind the body, and it doesn't stop a captured request from being replayed. Make the costly work idempotent as well: a unique constraint on credit_grants (user_id) turns a replay into a failed insert.
For an Auth Hook, don't invent anything. Verify the signed headers with the standardwebhooks package against the raw body (the same raw-body rule as Stripe):
import { Webhook } from "standardwebhooks";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const secret = process.env.SEND_EMAIL_HOOK_SECRET;
if (!secret) return NextResponse.json({ error: "not configured" }, { status: 500 });
// Supabase's own example strips the "v1,whsec_" prefix before new Webhook().
const wh = new Webhook(secret.replace("v1,whsec_", ""));
const payload = await request.text(); // raw body, never request.json() first
try {
const event = wh.verify(payload, Object.fromEntries(request.headers));
// ...send the email described by event
} catch {
return NextResponse.json({ error: "invalid signature" }, { status: 401 });
}
return NextResponse.json({});
}
verify() throws on a bad signature and on a webhook-timestamp more than five minutes off, which gives you the replay window a static token lacks.
What GuardLayer checks, and what it can't see
nextjs/webhook-no-signature-verification treats a file as a webhook receiver when its path contains webhook or its code names a provider signature header such as stripe-signature or svix-signature. If that file reads the request body, the rule looks for a recognised verification step, such as timingSafeEqual, createHmac, constructEvent, new Webhook(...) or a function named like verify…Signature, verify…Webhook or verify…Header, and flags the route when it finds none. It doesn't prove the comparison is correct or constant-time, or that it runs first. On variants of the route above:
- A plain
!==againstprocess.env.SUPABASE_WEBHOOK_TOKENthat doesn't return a 401 or 403 is still flagged. - A plain
===inside a helper namedverifyWebhookToken()is not flagged. The rule matches the name, not the logic, so a clean scan doesn't mean your comparison is timing-safe. - An Auth Hook receiver at
app/api/auth/hooks/send-email/route.tsisn't treated as a webhook: nowebhookin the path, andwebhook-signatureisn't a header the rule recognises. At most it getsnextjs/api-route-no-input-validation, and only if the payload reaches a dangerous sink such as a whole-body database write; that rule is about where input goes, not signatures. - An
sb_secret_key pasted into committed trigger SQL firesgeneral/hardcoded-secret. A random token you generated, in the same place, fires nothing.
GuardLayer reads the repo, so a webhook that exists only in the dashboard is invisible to it.
Quick self-check
-- Every webhook trigger built on http_request, with the headers it sends
select tgrelid::regclass as table_name, tgname, pg_get_triggerdef(oid)
from pg_trigger
where not tgisinternal
and pg_get_triggerdef(oid) ilike '%http_request%';
An sb_secret_ value, or a Bearer eyJ… JWT whose payload says service_role, is an RLS-bypassing key stored in plain text and sent to whatever URL the trigger names. Headers with nothing but Content-Type mean anyone can forge the request.
# Webhook route files that never call a known verification primitive
grep -rLE --include='route.*' "timingSafeEqual|createHmac|constructEvent|new Webhook" app src/app 2>/dev/null | grep -i webhook
Then replay the forged curl from earlier against your deployed route, with "record": null so the handler has nothing to grant. Anything but a 401 means it still trusts the body.
FAQ
Does Supabase sign Database Webhooks?
No. They are plain pg_net requests carrying the headers you configured. The feature request to sign them (#2691) was closed for inactivity in August 2025 without being built.
Where do I find the Supabase Auth Hook secret?
In the Auth Hooks section of the dashboard, where you generate it while configuring an HTTP hook. It looks like v1,whsec_<base64-secret>; strip the v1,whsec_ prefix before passing it to new Webhook().
Can I put my service_role or secret key in the webhook's Authorization header?
Not for your own route or an automation tool. Send a dedicated random token so the receiver never holds a key that bypasses RLS. If the receiver is a Supabase Edge Function, the documented path is a secret key on the apikey header, stored in Vault.
Can a Dashboard Database Webhook read its header from Vault?
No. supabase_functions.http_request takes its headers as a literal trigger argument. Write your own trigger function that calls net.http_post and selects the token from vault.decrypted_secrets.
Catch this before it ships — free
Scan your files for this and 35 other Next.js + Supabase issues, with the exact fix inline. Connect GitHub and GuardLayer checks every push too — free on public repos.
No signup, no card. Raw files aren’t kept; only the report is saved.