HomeGuidesVerify webhooks in Next.js: Clerk, Resend, GitHub, Supabase
Guide
Verify webhooks in Next.js: Clerk, Resend, GitHub, Supabase
Verify Clerk, Resend, GitHub, Paddle, Lemon Squeezy and Supabase webhooks in Next.js: raw body, signature, timestamp, constant-time compare, duplicates.
Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.
Every webhook route in your app is a public URL that changes data when someone POSTs to it. Clerk, Resend, GitHub, Paddle, Lemon Squeezy and Supabase all call those routes from their servers, and so can anyone else who finds the path. The only thing that tells a real event from a forged one is a check you write: verify the signature (or shared secret) on the exact raw body, reject stale timestamps, and record the event id so a replay or retry runs once.
AI coding tools get the first part wrong in predictable ways. They parse the JSON before verifying, read headers with the wrong syntax, skip the check when the header is missing, or, after verification keeps failing, delete it. This guide gives a tested Next.js App Router handler for each provider, one shared helper for duplicates, and the mistakes to look for.
Stripe has its own page: verify Stripe webhook signatures. The ideas here are the same, applied to the other services vibe-coded apps usually wire up.
Checked and tested on October 21, 2026 against the Clerk, Svix, Resend, GitHub, Paddle, Lemon Squeezy, Supabase, Standard Webhooks and Next.js docs linked below. We built every route on this page in a Next.js 16.4.2 app with @clerk/nextjs 7.9.19, svix 1.99.1, resend 6.34.1, @octokit/webhooks-methods 6.0.0, @paddle/paddle-node-sdk 3.10.0, standardwebhooks 1.1.1 and @supabase/supabase-js 2.118.0, backed by PostgreSQL 17.11 and PostgREST 16.5, and sent it signed, unsigned, tampered, stale and replayed requests: 73 checks, all passing. VibeLocked is independent and not affiliated with any of these companies.
The short version
- Read the raw body once with
await req.text()and verify that string. Never verifyJSON.stringify(await req.json()). - Use the provider's library when there is one:
verifyWebhookfor Clerk,resend.webhooks.verifyfor Resend,verifyfrom@octokit/webhooks-methodsfor GitHub,paddle.webhooks.unmarshalfor Paddle,standardwebhooksfor Supabase Auth Hooks. - Reject when the signature header is missing. A missing header is a failed check, not a reason to skip it.
- Compare secrets in constant time, with a helper that does not throw when lengths differ.
- Let the library reject old timestamps, and dedupe on the event id (
svix-id,X-GitHub-Delivery, Paddleevent_id) so a replay or retry runs once. - Supabase Database Webhooks are not signed. Send a random secret in a header, keep it in Vault, and check it.
- Keep signing secrets server-only, one per endpoint, never with a
NEXT_PUBLIC_prefix.
Why an unverified webhook route is a backdoor
A webhook handler usually does something an ordinary user should not be able to do: create or delete a user row, mark an email address as bounced, grant a subscription, fulfill an order. If the route does not verify the request, anyone can do those things with one request:
curl -X POST https://your-app.com/api/webhooks/lemonsqueezy \
-H 'content-type: application/json' \
-d '{"meta":{"event_name":"subscription_updated"},"data":{"id":"123","attributes":{"status":"active"}}}'
Webhook paths are easy to guess (/api/webhooks, /api/webhooks/clerk, /api/stripe/webhook) and they show up in public repos, tutorials and AI-generated code. Clerk's webhooks overview puts it plainly: treat incoming events as unsafe until you validate them, because a bad actor will fake an event to gain access to your app or data.
The fix is the same everywhere. The provider signs each delivery with a secret only you and it know. You recompute the signature over the bytes you received and compare.
Rule 1: verify the raw body, not parsed JSON
Every provider on this page signs the exact bytes it sent. Resend's verification docs warn that some frameworks parse the body as JSON and stringify it again, which breaks verification. Paddle's signature docs say not to transform the raw body at all, not even whitespace. Svix says the signature is "sensitive to any changes."
In an App Router route handler that means await req.text(), once, before anything else touches the body. Parsing JSON and stringifying it again changes bytes. In our test, this body:
{"type": "email.bounced", "data": {"amount": 10.50, "name": "Jos\u00e9"}}
came back from JSON.stringify(JSON.parse(raw)) without the spaces, with 10.5 instead of 10.50, and with é instead of \u00e9. The signature over the original verified. The signature over the re-serialized string failed. Worse, a test event with no spaces and no special characters can pass by accident, so the bug ships and breaks on the first real payload.
If you use the Pages Router, the Next.js API routes docs say to export config = { api: { bodyParser: false } } for webhook routes so you can read the raw body yourself. App Router route handlers do not parse the body for you, so req.text() is enough.
Rule 2: read headers with .get()
req.headers in a route handler is a Headers object, not a plain object. req.headers['svix-id'] is undefined. We checked: a Request built with valid Svix headers returned undefined for bracket access and the right value for req.headers.get('svix-id'), and resend.webhooks.verify threw on every delivery when given the bracket values. Resend's own Next.js example on its docs page uses bracket access, so this one gets copied a lot. When a tool tells you "verification fails for every request, let's remove it," check this first.
Rule 3: compare in constant time, without throwing
A normal === stops at the first different character, which leaks timing. GitHub's validation docs say never to use a plain == and to use something like crypto.timingSafeEqual. But Node's timingSafeEqual throws a RangeError when the two buffers have different lengths, which we reproduced. An attacker who sends x-signature: abc then gets a 500 and an error in your logs instead of a clean 401. Lemon Squeezy's docs example calls timingSafeEqual directly, so it has this edge.
The libraries below handle this for you. For the two providers where you compare by hand (Lemon Squeezy and Supabase Database Webhooks), hash both sides first so the lengths always match:
// lib/webhooks/safe-equal.ts
import { createHash, timingSafeEqual } from 'node:crypto';
// Constant-time compare for two strings of any length.
// timingSafeEqual throws on different lengths, so compare fixed-size hashes.
export function safeEqual(a: string, b: string): boolean {
const ha = createHash('sha256').update(a).digest();
const hb = createHash('sha256').update(b).digest();
return timingSafeEqual(ha, hb);
}
In our test it returned false for abc against abcd without throwing, and true for equal strings.
Rule 4: reject replays and run each event once
A signature proves who sent a request, not when. Anyone who captures a valid delivery (from a log, a proxy, a shared debugging session) can send it again. Two defenses work together:
- A signed timestamp with a short window. Svix (Clerk, Resend) and Standard Webhooks (Supabase Auth Hooks) sign the timestamp along with the body, and their libraries reject messages more than 5 minutes old. The Paddle SDK's window is 5 seconds. GitHub and Lemon Squeezy sign no timestamp.
- An event id you have already seen. Providers also retry. Clerk's docs say every delivery attempt for the same event carries the same
svix-id, and GitHub's best practices say a redelivery keeps the sameX-GitHub-Deliveryheader and recommend it for replay protection.
So every handler below ends with one shared helper. It inserts the provider and event id into a table with a primary key. The first insert wins; a duplicate hits a unique violation (23505, which PostgREST passes through) and returns 200 without doing the work again. If the work fails, it removes the claim and returns 500 so the provider retries.
create table public.webhook_events (
provider text not null,
event_id text not null,
received_at timestamptz not null default now(),
primary key (provider, event_id)
);
-- Only your server (secret key) touches this table.
alter table public.webhook_events enable row level security;
revoke all on public.webhook_events from anon, authenticated;
// lib/webhooks/process-once.ts
import { createClient } from '@supabase/supabase-js';
// Server only. Never import this file from a client component.
const admin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!,
{ auth: { persistSession: false } },
);
// Runs `work` once per provider + event id. A duplicate or replayed delivery
// gets a 200 without running it again. If `work` fails, the claim is removed
// and we return 500 so the provider retries.
export async function processOnce(
provider: string,
eventId: string,
work: () => Promise<void>,
): Promise<Response> {
const { error } = await admin
.from('webhook_events')
.insert({ provider, event_id: eventId });
if (error?.code === '23505') {
return new Response('Already processed', { status: 200 });
}
if (error) {
console.error('webhook claim failed', provider, error.message);
return new Response('Try again', { status: 500 });
}
try {
await work();
} catch (err) {
await admin
.from('webhook_events')
.delete()
.match({ provider, event_id: eventId });
console.error('webhook handler failed', provider, eventId, err);
return new Response('Processing failed', { status: 500 });
}
return new Response('OK', { status: 200 });
}
This file uses your Supabase secret key (or the legacy service_role key), so it must only ever run on the server. The anon vs service role key guide explains why. With RLS on and no policies, the publishable (anon) key cannot read the table or insert fake claims: both returned 401 in our test.
Returning 200 for a duplicate matters. A 4xx or 5xx tells the provider to keep retrying, and Clerk's sync guide says any 2xx marks the event as delivered.
Clerk (Svix)
Clerk sends webhooks through Svix. Use verifyWebhook from @clerk/nextjs/webhooks. It reads CLERK_WEBHOOK_SIGNING_SECRET, reads the raw body itself, and checks the svix-id, svix-timestamp and svix-signature headers.
// app/api/webhooks/clerk/route.ts
import { verifyWebhook } from '@clerk/nextjs/webhooks';
import type { NextRequest } from 'next/server';
import { processOnce } from '@/lib/webhooks/process-once';
import { upsertUser, deleteUser } from '@/lib/app';
export async function POST(req: NextRequest) {
let evt;
try {
// Reads CLERK_WEBHOOK_SIGNING_SECRET, the raw body and the svix-* headers.
// Throws on a bad signature or a timestamp more than 5 minutes off.
evt = await verifyWebhook(req);
} catch {
return new Response('Invalid signature', { status: 400 });
}
// verifyWebhook already required this header, and retries reuse it.
const eventId = req.headers.get('svix-id')!;
return processOnce('clerk', eventId, async () => {
if (evt.type === 'user.created' || evt.type === 'user.updated') {
await upsertUser(evt.data);
}
if (evt.type === 'user.deleted' && evt.data.id) {
await deleteUser(evt.data.id);
}
});
}
What we saw: a signed user.created returned 200 and ran the handler once. Sending the same delivery again returned Already processed and the handler count stayed at 1. Unsigned, tampered, wrong-secret and 6-minute-old requests all returned 400. A 4-minute-old one was accepted. When the handler threw, the route returned 500 and the claim was removed so Clerk's retry can run it.
Three Clerk details:
- Keep the route public. The sync guide says incoming webhooks carry no auth information, so
/api/webhooks(.*)must not be protected inclerkMiddleware(). The signature check is the authentication for this route. Exclude it from protection. Do not make the handler skip verification. - Events can arrive out of order. The webhooks overview says a
user.updatedcan arrive beforeuser.created. Use an upsert, and compareupdated_atbefore overwriting newer data. - The signing secret is per endpoint and starts with
whsec_. Rotating it is covered in the Clerk keys guide.
Resend (Svix)
Resend also signs with Svix. The SDK's resend.webhooks.verify takes the raw payload, the three headers and the endpoint's signing secret, and throws when anything is off.
// app/api/webhooks/resend/route.ts
import { Resend } from 'resend';
import type { NextRequest } from 'next/server';
import { processOnce } from '@/lib/webhooks/process-once';
import { markBounced } from '@/lib/app';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req: NextRequest) {
const payload = await req.text(); // raw body, before any JSON parsing
const id = req.headers.get('svix-id') ?? '';
let event;
try {
event = resend.webhooks.verify({
payload,
headers: {
id,
timestamp: req.headers.get('svix-timestamp') ?? '',
signature: req.headers.get('svix-signature') ?? '',
},
webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
});
} catch {
return new Response('Invalid signature', { status: 400 });
}
return processOnce('resend', id, async () => {
if (event.type === 'email.bounced') {
await markBounced(event.data.to);
}
});
}
A signed email.bounced returned 200 and ran once. The replay returned Already processed. Missing headers and a tampered body returned 400. A request signed with the Clerk endpoint's secret also returned 400: each endpoint has its own secret, so one variable per provider. If you only verify and never call the Resend API in this route, the plain svix package works the same way: new Webhook(secret).verify(payload, headers).
GitHub
GitHub sends an HMAC SHA-256 hex digest of the body in X-Hub-Signature-256, prefixed with sha256=. verify from @octokit/webhooks-methods checks it in constant time.
// app/api/webhooks/github/route.ts
import { verify } from '@octokit/webhooks-methods';
import type { NextRequest } from 'next/server';
import { processOnce } from '@/lib/webhooks/process-once';
import { handlePush } from '@/lib/app';
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('x-hub-signature-256');
const deliveryId = req.headers.get('x-github-delivery');
// No secret configured on GitHub means no header: reject, never skip.
if (!signature || !deliveryId) {
return new Response('Missing signature', { status: 401 });
}
const valid = await verify(process.env.GITHUB_WEBHOOK_SECRET!, body, signature)
.catch(() => false);
if (!valid) {
return new Response('Invalid signature', { status: 401 });
}
// GitHub signs no timestamp, so the delivery id is your replay guard.
return processOnce('github', deliveryId, async () => {
if (req.headers.get('x-github-event') === 'push') {
const payload = JSON.parse(body);
await handlePush(payload.repository.full_name);
}
});
}
GitHub's docs include a test vector: secret It's a Secret to Everybody, payload Hello, World!, signature sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17. verify accepted it in our test, which is a quick way to prove your setup before you touch real events.
Also tested: a signed push ran once, its replay with the same delivery id returned Already processed, and requests with no signature, only the legacy SHA-1 X-Hub-Signature header, a malformed sha256=zz value, or a tampered body all returned 401. The .catch(() => false) keeps a malformed header from turning into a 500.
GitHub details that matter:
- If you never set a secret on the webhook, GitHub sends no
X-Hub-Signature-256header at all. That is why the route rejects a missing header instead of treating it as "nothing to check." - GitHub signs no timestamp, so the
X-GitHub-Deliverydedupe is your replay protection. - Check
X-GitHub-Eventand the payload'sactionbefore acting. GitHub's best practices say new event types and actions get added over time. - GitHub says to respond within 10 seconds. Queue slow work.
Lemon Squeezy
Lemon Squeezy sends an HMAC SHA-256 hex digest of the raw body in X-Signature, computed with the signing secret you typed when you created the webhook. There is no SDK helper for this, so compare by hand with safeEqual.
// app/api/webhooks/lemonsqueezy/route.ts
import { createHash, createHmac } from 'node:crypto';
import type { NextRequest } from 'next/server';
import { safeEqual } from '@/lib/webhooks/safe-equal';
import { processOnce } from '@/lib/webhooks/process-once';
import { syncSubscription, fulfillOrder } from '@/lib/app';
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('x-signature') ?? '';
const expected = createHmac('sha256', process.env.LEMONSQUEEZY_WEBHOOK_SECRET!)
.update(body)
.digest('hex');
if (!signature || !safeEqual(expected, signature)) {
return new Response('Invalid signature', { status: 401 });
}
// No delivery id header and no signed timestamp: a replay is byte-identical,
// so a hash of the raw body works as the dedupe key.
const eventId = createHash('sha256').update(body).digest('hex');
const event = JSON.parse(body);
const name: string = event.meta.event_name;
return processOnce('lemonsqueezy', eventId, async () => {
if (name.startsWith('subscription_')) await syncSubscription(event.data.id);
if (name === 'order_created') await fulfillOrder(event.data.id);
});
}
A signed subscription_updated ran once. The byte-identical replay returned Already processed. A short wrong signature returned 401 (not 500), and missing or tampered requests returned 401.
The signing docs let you choose the secret, between 6 and 40 characters. Use the full 40, generated randomly, not a word. Lemon Squeezy retries up to three more times when you do not return 200, and the request carries X-Event-Name and X-Signature but no signed timestamp or delivery id, which is why the dedupe key is a hash of the body.
Paddle
Paddle Billing sends Paddle-Signature: ts=...;h1=..., where h1 is an HMAC SHA-256 of ts:rawBody with your notification destination's secret key. The Node SDK's paddle.webhooks.unmarshal verifies it and returns a typed event.
// app/api/webhooks/paddle/route.ts
import { Paddle, EventName } from '@paddle/paddle-node-sdk';
import type { NextRequest } from 'next/server';
import { processOnce } from '@/lib/webhooks/process-once';
import { syncSubscription, fulfillOrder } from '@/lib/app';
const paddle = new Paddle(process.env.PADDLE_API_KEY!);
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('paddle-signature') ?? '';
let event;
try {
// Checks the h1 HMAC over "ts:body" and rejects a ts older than 5 seconds.
event = await paddle.webhooks.unmarshal(
body,
process.env.PADDLE_WEBHOOK_SECRET!,
signature,
);
} catch {
return new Response('Invalid signature', { status: 401 });
}
return processOnce('paddle', event.eventId, async () => {
if (event.eventType === EventName.SubscriptionUpdated) {
await syncSubscription(event.data.id);
}
if (event.eventType === EventName.TransactionCompleted) {
await fulfillOrder(event.data.id);
}
});
}
A freshly signed subscription.updated returned 200 and ran once. The same event_id, signed again with a new timestamp, returned Already processed. That case is why the dedupe matters even with a timestamp check. A request signed 10 seconds earlier returned 401. Paddle's docs say the SDKs default to a five-second tolerance, so your server clock has to be accurate. Tampered, unsigned and wrong-secret requests returned 401.
If unmarshal throws for a correctly signed body, the payload may be missing fields the SDK expects. Our first test payload did that. The route treats it as a failure (401), which is safer than processing a half-parsed event.
Supabase Database Webhooks: not signed, so add a secret
Supabase Database Webhooks are a wrapper around a Postgres trigger that calls pg_net. The request carries only the headers you configure. There is no signature. Without a check of your own, anyone can POST a fake "order paid" row change to your route.
Send a long random secret in a custom header and compare it in constant time. Supabase's new API keys guide warns that values in SQL or in a webhook configuration are stored in plain text, and recommends Vault. So instead of typing the secret into the dashboard form, generate it inside Vault and read it in the trigger:
-- 1. Generate the shared secret inside Vault (never hardcode it in SQL).
select vault.create_secret(
encode(extensions.gen_random_bytes(32), 'hex'),
'order_webhook_secret'
);
-- 2. A trigger function in a schema the API does not expose.
create schema if not exists private;
create or replace function private.notify_order_paid()
returns trigger
language plpgsql
security definer -- runs as its owner so it can read Vault
set search_path = ''
as $$
begin
perform net.http_post(
url := 'https://your-app.com/api/webhooks/supabase',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'x-webhook-secret', (
select decrypted_secret from vault.decrypted_secrets
where name = 'order_webhook_secret'
)
),
body := jsonb_build_object(
'type', tg_op,
'table', tg_table_name,
'schema', tg_table_schema,
'record', to_jsonb(new),
'old_record', to_jsonb(old)
)
);
return new;
end;
$$;
revoke execute on function private.notify_order_paid() from public, anon, authenticated;
-- 3. Fire only on the change you care about.
create trigger orders_paid_webhook
after update of status on public.orders
for each row
when (new.status = 'paid' and old.status is distinct from 'paid')
execute function private.notify_order_paid();
Read the generated value once in the SQL editor (select decrypted_secret from vault.decrypted_secrets where name = 'order_webhook_secret';) and add it as SUPABASE_WEBHOOK_SECRET in your hosting provider's environment variable settings, nowhere else. Then the route:
// app/api/webhooks/supabase/route.ts
import type { NextRequest } from 'next/server';
import { safeEqual } from '@/lib/webhooks/safe-equal';
import { processOnce } from '@/lib/webhooks/process-once';
import { fulfillOrder } from '@/lib/app';
type OrderRow = { id: string; status: string };
type Payload = {
type: 'INSERT' | 'UPDATE' | 'DELETE';
table: string;
schema: string;
record: OrderRow | null;
old_record: OrderRow | null;
};
export async function POST(req: NextRequest) {
// Supabase Database Webhooks are not signed. Send a long random secret in a
// custom header and compare it in constant time.
const secret = req.headers.get('x-webhook-secret') ?? '';
if (!secret || !safeEqual(secret, process.env.SUPABASE_WEBHOOK_SECRET!)) {
return new Response('Unauthorized', { status: 401 });
}
const payload = (await req.json()) as Payload;
if (payload.schema !== 'public' || payload.table !== 'orders') {
return new Response('Ignored', { status: 200 });
}
const order = payload.record;
const becamePaid =
payload.type === 'UPDATE' &&
order?.status === 'paid' &&
payload.old_record?.status !== 'paid';
if (!order || !becamePaid) {
return new Response('Ignored', { status: 200 });
}
// The payload has no event id, so key on the state change itself.
return processOnce('supabase', `order-paid:${order.id}`, () =>
fulfillOrder(order.id),
);
}
The trigger body uses the same type, table, schema, record and old_record shape as the dashboard webhooks, so the route works with either one. If you do use the dashboard form, add x-webhook-secret under HTTP Headers. Use a dedicated random value. Never use your secret key or service_role key as the shared secret.
We could not run real pg_net and Vault outside Supabase, so we tested the SQL against stand-ins with the same function signatures (net.http_post from the pg_net README, vault.create_secret and vault.decrypted_secrets). What we saw:
- An
authenticateduser updating an order topaidqueued exactly one request, with the 64-character Vault secret in the header. - That queued request, posted to the route, returned 200 and fulfilled the order once. Posting it again returned
Already processed. paidtopaidandpaidtoshippedqueued nothing, because of the trigger'swhenclause.- Without
security definer, the same update failed withpermission denied for schema vault. With it, the function runs as its owner. That is why it pinssearch_path = '', lives inprivate, and has execute revoked. The rpc functions guide explains those three habits. - The function in
privatereturned 404 through the API, and so diddecrypted_secrets. - No header, a wrong short secret, an unrelated table, or a no-op change returned 401 or
Ignored.
Supabase also has a signed product with a similar name. Platform Webhooks for organization and project events follow Standard Webhooks, like the Auth Hooks below.
Supabase Auth Hooks (Standard Webhooks)
HTTP Auth Hooks, such as Send Email, are signed. They follow the Standard Webhooks spec, with webhook-id, webhook-timestamp and webhook-signature headers. The dashboard shows the secret as v1,whsec_<base64>. Strip the v1,whsec_ part before you pass it to the library, as Supabase's Send Email hook example does. Passing the full string failed verification in our test.
// app/api/auth-hooks/send-email/route.ts
import { Webhook } from 'standardwebhooks';
import type { NextRequest } from 'next/server';
import { sendAuthEmail } from '@/lib/app';
// Supabase shows the secret as "v1,whsec_<base64>". The library wants the
// part after "v1,whsec_" (it also accepts a "whsec_" prefix).
const wh = new Webhook(process.env.SEND_EMAIL_HOOK_SECRETS!.replace('v1,whsec_', ''));
type SendEmailHook = {
user: { email: string };
email_data: { token: string; email_action_type: string };
};
export async function POST(req: NextRequest) {
const payload = await req.text();
let data: SendEmailHook;
try {
// Checks webhook-id, webhook-timestamp (5 minute window) and webhook-signature.
data = wh.verify(payload, Object.fromEntries(req.headers)) as SendEmailHook;
} catch {
return Response.json(
{ error: { http_code: 401, message: 'Invalid signature' } },
{ status: 401 },
);
}
await sendAuthEmail(data.user.email, data.email_data);
return Response.json({});
}
A signed hook returned 200 with {} and sent once. A tampered body and a 10-minute-old one returned 401 with a JSON content type, which Supabase requires for every hook response. When the hook runs as an Edge Function instead, it needs --no-verify-jwt because Auth calls it before any user JWT exists. The signature replaces that check. More on that in Supabase Edge Functions auth.
Rotating a signing secret without dropping events
Svix signature headers can carry several space-separated signatures, and the library accepts the request if any one matches. In our test, a header signed with the old and the new secret verified against the new one. That is how a rotation window works on Svix-based providers. For Clerk, the keys guide walks through adding a second endpoint and deleting the old one. For GitHub and Lemon Squeezy, update the secret on the provider and in your env vars, then redeploy right away. Requests signed with the other value fail in between, and both providers let you redeliver.
If a signing secret leaks, treat it like any other key: rotate it first, then check what was processed. The first hour after a key leaks has the order of steps.
Common AI-generated mistakes
Look for these in your webhook routes. The first six are reproduced in our test file:
JSON.stringify(await req.json())passed to verify. Changes the bytes. Works on a clean test event, fails on real ones.req.headers['svix-id']on a route handler request. Alwaysundefined, so every delivery fails, and the next "fix" is deleting the check.if (signature && !isValid(signature)) return 401. A request with no signature header skips the check entirely. We confirmed the naive version lets an unsigned request through.crypto.timingSafeEqualon raw strings of different lengths. Throws, so a garbage header becomes a 500.- A hand-rolled Svix check that skips the timestamp. Our manual version accepted a capture from an hour earlier. The library rejected it.
- The full
v1,whsec_...string passed tostandardwebhooks. Fails every Supabase Auth Hook. - No dedupe. A retry or replay inside the timestamp window sends a second welcome email or grants a second month.
- Verification wrapped in
if (process.env.NODE_ENV === 'production'), or skipped when the secret env var is missing. Then a missing variable on a new deploy silently turns the check off. - The signing secret in a
NEXT_PUBLIC_variable or committed to the repo. See keeping secrets out of frontend code. - The webhook route protected by session auth, so every delivery gets a 401 or a redirect to the sign-in page, followed by "fixing" it by turning off verification instead of making the route public. Webhooks never carry your user's cookie.
- A secret in the query string (
/api/webhook?key=...). GitHub's best practices say not to put credentials in the payload URL. - Trusting IDs in the payload without checking the event type. Branch on the event type and only act on the ones you subscribed to.
Test it yourself
Send your deployed route an unsigned request and expect a 401 or 400, never a 2xx:
curl -i -X POST https://your-app.com/api/webhooks/clerk \
-H 'content-type: application/json' \
-d '{"type":"user.created","data":{"id":"user_fake"}}'
Then use each provider's own test tool for the signed path: Clerk's Testing tab on the endpoint page, GitHub's Recent Deliveries and Redeliver buttons, and resend or test events in Resend, Paddle and Lemon Squeezy dashboards. Redeliver the same event twice and confirm your side effect happened once. Never paste a signing secret into a third-party website to "check" it.
Checklist
- Every webhook route reads the body once with
await req.text()and verifies that string - Clerk uses
verifyWebhook, Resendresend.webhooks.verify(orsvix), GitHub@octokit/webhooks-methods, Paddlepaddle.webhooks.unmarshal, Auth Hooksstandardwebhooks - A missing signature header returns 400 or 401
- Manual comparisons use a constant-time helper that does not throw on different lengths
- Signed timestamps are checked (the libraries do it), and the server clock is synced
- Each handler dedupes on the provider's event id, returns 200 for duplicates, and 500 when processing fails
- The dedupe table has RLS on, no policies, and is only touched with the server-side secret key
- Supabase Database Webhooks send a random secret header stored in Vault, checked in constant time
- Each endpoint has its own signing secret in a server-only env var, and none uses
NEXT_PUBLIC_ - Webhook paths are public in middleware or proxy, and the handler itself verifies
- Handlers check the event type and ignore events they do not handle
- An unsigned curl to every webhook route returns 400 or 401 in production
Copy-paste prompt for your AI coding tool
Paste this into Cursor, Claude Code, Lovable, or Bolt. Share code only, never real secrets, signing secrets, or production payloads.
Find every route in this repo that receives webhooks or hook calls from another service (Clerk, Resend, GitHub, Paddle, Lemon Squeezy, Stripe, Supabase Database Webhooks, Supabase Auth Hooks, or any route under /api/webhooks). For each one, tell me: 1) whether it reads the raw body with await req.text() before any parsing, and flag any req.json() or JSON.stringify before verification; 2) which library or code verifies the signature, and flag hand-rolled checks that skip the timestamp, use === or call timingSafeEqual on values of different lengths; 3) whether a missing signature header is rejected, and flag any if (signature && ...) pattern or a check that only runs in production or when an env var exists; 4) whether headers are read with req.headers.get() and not bracket access; 5) whether it records the provider's event id (svix-id, X-GitHub-Delivery, Paddle event_id, Stripe event id) with a unique constraint and returns 200 for duplicates; 6) whether any signing secret uses a NEXT_PUBLIC_ prefix, is hardcoded, or is shared between endpoints; 7) whether the route is blocked by auth middleware; 8) for Supabase Database Webhooks, whether a secret header is sent and compared in constant time. Then propose fixes using the official libraries (@clerk/nextjs/webhooks verifyWebhook, resend.webhooks.verify or svix, @octokit/webhooks-methods verify, @paddle/paddle-node-sdk unmarshal, standardwebhooks) and a webhook_events table with a primary key on (provider, event_id). Show me the diff before changing anything, and do not remove or weaken any verification to make a test pass.
Read the diff before you accept it, then run the unsigned curl test above against each route. More prompts are in the security review prompt guide.
Related guides
- Verify Stripe webhook signatures in Next.js and Supabase
- Stripe success page paywall bypass, and why only the verified webhook should grant access
- Stripe price tampering and credit race conditions, for fulfillment that runs once
- Clerk publishable key vs secret key, including the webhook signing secret
- Supabase Edge Functions auth and verify_jwt, for webhooks that land on Edge Functions
- Supabase SECURITY DEFINER functions anyone can call via rpc
- SSRF in Next.js, for the other direction: webhooks your app sends to customer URLs
- Next.js middleware is not your auth boundary
- Ship checklist for a vibe-coded app
Sources
- Clerk: Webhooks overview (treat events as unsafe, at-least-once delivery, same svix-id on retries, out-of-order events)
- Clerk: Sync Clerk data with webhooks (verifyWebhook route handler, public route, 2xx stops retries)
- Clerk: verifyWebhook reference
- Clerk: clerkMiddleware reference
- Svix: Verifying webhooks with the libraries
- Svix: Verifying webhooks manually (signed content, multiple signatures, timestamp check)
- Svix: Why verify webhooks (replay attacks)
- Standard Webhooks specification
- Resend: Verify webhook requests (raw body, resend.webhooks.verify)
- Resend: Webhooks introduction
- GitHub: Validating webhook deliveries (X-Hub-Signature-256, constant-time compare, test vector)
- GitHub: Best practices for using webhooks (X-GitHub-Delivery replay protection, no secrets in URLs, 10 second response)
- GitHub: Webhook events and payloads (delivery headers)
- octokit/webhooks-methods.js (sign and verify)
- Paddle: Verify webhook signatures (ts and h1, raw body, five-second SDK tolerance)
- Paddle Node.js SDK
- Lemon Squeezy: Signing requests (X-Signature HMAC)
- Lemon Squeezy: Webhook requests (headers and retries)
- Lemon Squeezy: Webhooks (signing secret length)
- Supabase: Database Webhooks (pg_net trigger, payload shape)
- Supabase: pg_net extension
- supabase/pg_net README (net.http_post signature)
- Supabase: Vault
- Supabase: Migrating to publishable and secret API keys (webhook headers stored in plain text, use Vault)
- Supabase: Auth Hooks (Standard Webhooks headers, v1,whsec_ secrets, JSON responses)
- Supabase: Send Email Hook
- Supabase: Platform Webhooks
- Supabase: API keys
- PostgREST: Errors (23505 unique violation)
- PostgreSQL: CREATE TRIGGER (WHEN condition)
- PostgreSQL: Trigger functions in PL/pgSQL (TG_OP, NEW, OLD)
- Next.js: Route Handlers (route.ts)
- Next.js: API Routes (bodyParser false for webhooks)
- Next.js: proxy.js (formerly middleware)
- Node.js: crypto.timingSafeEqual
Questions
How do I verify a Clerk webhook in Next.js?
Call verifyWebhook(req) from @clerk/nextjs/webhooks in an App Router route handler, inside a try block, and return 400 if it throws. It reads CLERK_WEBHOOK_SIGNING_SECRET, the raw body and the svix-id, svix-timestamp and svix-signature headers, and rejects messages more than 5 minutes old. Keep the route public in your Clerk middleware, and store the svix-id so a retried event runs only once.
Why does my webhook signature verification keep failing?
Usually because the code verifies a different body than the one that was signed. Calling req.json() and then JSON.stringify changes spacing, escapes and number formats, so the signature no longer matches. Read the body once with await req.text() and verify that string. Other common causes: reading headers with req.headers['name'] instead of req.headers.get('name'), using the secret from a different endpoint or environment, or a server clock that is off.
Are Supabase Database Webhooks signed?
No. Database Webhooks are triggers that call pg_net, and the request carries only the headers you configure. Add a long random secret in a custom header, store it in Vault instead of hardcoding it, and compare it in your route with a constant-time check. Supabase Auth Hooks are different: they follow the Standard Webhooks spec and are signed, so verify them with the standardwebhooks library.
Do I need to check the webhook timestamp?
Yes, if the provider signs one. A valid signed request can be captured and sent again later. Svix, Standard Webhooks and Paddle include a signed timestamp, and their libraries reject old messages (5 minutes for Svix, 5 seconds for the Paddle SDK). GitHub and Lemon Squeezy sign no timestamp, so dedupe on the X-GitHub-Delivery id or a hash of the raw body instead.
Is a webhook secret in the URL query string good enough?
No. GitHub's own guidance says not to put credentials in the payload URL. URLs end up in logs, dashboards and browser history, and a secret in the URL proves nothing about the body. Use the provider's signature. For providers that do not sign, like Supabase Database Webhooks, put the secret in a header and compare it in constant time.