Webhook signature verification: stop fake events in your Next.js app
Webhook signature verification means recomputing an HMAC SHA-256 (a hash only someone with your secret can reproduce) over the raw body, then comparing it to the signature header with a timing-safe compare. Some providers also sign an id and timestamp. Do it before you touch your database, or anyone who finds your URL can send fake events. This covers GitHub, Svix, Lemon Squeezy and Paddle.
QUICK CHECKLIST
- Read the body with req.text() before parsing anything
- Verify the raw body with the provider's library or documented HMAC
- Compare with crypto.timingSafeEqual after a length check
- Reject stale timestamps where the provider sends one
- Dedupe on the event id before you act on it
- Use one secret per endpoint, kept server-side
What webhook signature verification does
A webhook is a URL on your server that another service calls when something happens: a payment went through, a user signed up, an email bounced. The URL is public. Anything that can send an HTTP POST can call it, and your server can't tell a real sender from a fake by reading the JSON.
The signature fixes that. You and the provider share a secret, and the provider computes an HMAC SHA-256 (a keyed hash, a fingerprint only the secret holder can produce) over the request body. It puts the result in a header. You compute the same value from what you received and compare. If they match, the sender knew the secret and the body wasn't changed on the way.
GitHub's docs say to validate the signature so your server only processes deliveries sent by GitHub and so you can catch tampering. The open Standard Webhooks spec names spoofing and replay attacks as the threats to handle.
Stripe has its own helper library, covered in Stripe webhook signature verification. This guide is about everything else.
How an attacker uses an unsigned webhook
Webhook URLs are easy to guess: /api/webhook, /api/webhooks/github, /api/clerk. The attacker sends a POST with JSON that looks like the real thing. A handler with no signature check acts on it.
Three things can go wrong:
- A forged event. A fake
order_createdunlocks a paid plan, or a fakeuser.deletedmakes your app delete a real person's rows. - A tampered event. Someone changes an amount or a user id in transit and your code believes it.
- A replay attack. Someone resends a real, correctly signed delivery. The signature is still valid, so the credits get added twice.
GitHub's signature covers the payload contents, and Lemon Squeezy's is a hash of the payload. Neither docs page describes a signed timestamp. A valid signature alone doesn't prove the delivery is new, so you also need the event id check below.
The signing secret is a credential too. Keep it out of NEXT_PUBLIC_ variables (why those are public), never commit it, and rotate it if it leaks (what to do about a leaked API key).
Wrong and right: HMAC over the raw body
Generated handlers usually fail in two places. They call req.json() and hash JSON.stringify(event), and they compare the result with ==.
The first breaks because the provider signed the exact bytes it sent. Re-serializing can change whitespace, escaping or number formatting, so the HMAC no longer matches. Svix's docs warn about frameworks that parse the body and stringify it again.
The second leaks timing. A plain comparison can stop at the first different character, so response time hints at how much of a guess was right. GitHub's docs say never to use a plain == and to use something like crypto.timingSafeEqual.
import { createHmac } from "node:crypto"
export async function POST(req: Request) {
const event = await req.json()
const received = req.headers.get("x-signature")
const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!)
.update(JSON.stringify(event)) // not the original bytes
.digest("hex")
if (received == expected) await handleEvent(event) // timing leak
return Response.json({ received: true })
}import { createHmac, timingSafeEqual } from "node:crypto"
export async function POST(req: Request) {
const raw = await req.text() // exact bytes the provider signed
const received = req.headers.get("x-signature") ?? ""
const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!)
.update(raw, "utf8")
.digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(received)
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response("Bad signature", { status: 400 })
}
await handleEvent(JSON.parse(raw)) // safe to act now
return Response.json({ received: true })
}For GitHub, read x-hub-signature-256 and put sha256= in front of your hex digest before you compare.
Verify webhooks from GitHub, Svix, Lemon Squeezy and Paddle
Header names and formats differ by provider. The pattern stays the same.
| Signature header | Replay and dedupe | |
|---|---|---|
| GitHub | X-Hub-Signature-256, sha256= plus hex | Payload only. Dedupe on a body hash |
| Lemon Squeezy | X-Signature, hex HMAC SHA-256 | Payload only. Dedupe yourself |
| Svix (Clerk, Resend) | svix-signature, svix-id, svix-timestamp | Library checks timestamp. Dedupe on svix-id |
| Paddle | Own scheme, check current Paddle docs | Use the official SDK to verify |
Use X-Hub-Signature-256 for GitHub. X-Hub-Signature is the SHA-1 version, kept for legacy setups.
The Lemon Squeezy webhook signature is a plain hex HMAC SHA-256 in X-Signature. You choose the signing secret when you create the webhook, so make it long and random. The fixed code above matches Lemon Squeezy's Node example, plus a length check.
To verify a webhook with Svix, install the svix package. It checks the signature and the timestamp and throws if either is wrong. Svix implements the Standard Webhooks spec, so its svix-* headers carry the same values as the spec's webhook-* headers.
import { Webhook } from "svix"
export async function POST(req: Request) {
const body = await req.text()
try {
new Webhook(process.env.WEBHOOK_SECRET!).verify(body, Object.fromEntries(req.headers))
} catch {
return new Response("Bad signature", { status: 400 })
}
const event = JSON.parse(body)
// safe to act on event now
return new Response("OK")
}Paddle uses its own signing scheme. Don't copy the Lemon Squeezy or Stripe code. Use Paddle's official SDK or its current docs.
How to fix webhook signature verification, step by step
Apply these to every webhook route in your app, one provider at a time.
Read the raw body first
Call
await req.text()in the route handler and parse JSON only after the signature passes. In Express, don't putexpress.json()in front of the webhook route.Recompute and compare safely
Use the provider's library if there is one. Otherwise compute HMAC SHA-256 with
node:crypto, check the buffer lengths, then compare withcrypto.timingSafeEqual. Read the right header, for exampleX-Hub-Signature-256and not the legacy one.Check the timestamp
Svix and Standard Webhooks senders include a timestamp, and the Svix library checks it for you. If you verify by hand, make sure the timestamp is part of the signed content, then reject anything more than a few minutes old (5 minutes is a common tolerance). GitHub and Lemon Squeezy signatures cover only the payload, so a replayed delivery still verifies. Those need the next step.
Dedupe on the event id
Providers retry and attackers replay, so store each event id with a unique constraint and skip ids you've seen. Use
svix-idfor Svix, because the signature covers it. For GitHub, Lemon Squeezy and other payload-only signatures, key on a SHA-256 of the raw body, since an attacker can change unsigned headers likeX-GitHub-Delivery.Use one secret per endpoint
Create a separate signing secret for each endpoint and environment, so a leaked dev secret can't sign production events. Keep it in a server-only env var. If it leaks, change it in the provider dashboard and redeploy.
Create a webhook_events table with id text primary key and turn on row level security with no policies. Then only the Supabase service role key can touch it (see Supabase RLS mistakes). Postgres reports a unique-constraint violation, error 23505, when the id already exists.
import { createClient } from "@supabase/supabase-js"
const admin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // server only
)
export async function claimEvent(id: string) {
const { error } = await admin.from("webhook_events").insert({ id })
if (error?.code === "23505") return false // already seen
if (error) throw error
return true
}Call claimEvent right after verification. If it returns false, answer 200 so the provider stops retrying. If your handler fails afterwards, delete the row and return 500 so the retry can run.
Test it yourself
First, send an unsigned request:
curl -i -X POST http://localhost:3000/api/webhooks/lemonsqueezy \
-H "content-type: application/json" \
-d '{"meta":{"event_name":"order_created"}}'A bad result is a 200 and a changed row in your database. A good result is a 400 and no change.
Next, sign a body with the secret your local server uses (here a GitHub-style header):
export WEBHOOK_SECRET="your-local-test-secret"
BODY='{"action":"ping"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $NF}')
curl -i -X POST http://localhost:3000/api/webhooks/github \
-H "x-hub-signature-256: sha256=$SIG" \
-H "content-type: application/json" \
-d "$BODY"Then try three variations:
- Send the exact same request twice. The second should return 200 and write nothing new.
- Change one character in the body but keep the old signature. You should get 400.
- Remove the header. You should get 400, not a crash.
For a unit test of your HMAC function, GitHub publishes a test vector. With secret It's a Secret to Everybody and payload Hello, World!, the hex digest is 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17.
Fix prompt you can paste
Give this to Cursor or Claude Code in your repo root:
Find every webhook route handler in this repo (for example app/api/**/webhook*/route.ts, and any code handling GitHub, Clerk, Svix, Resend, Lemon Squeezy, Paddle or Stripe events). First, report every problem as file:line with a one-line description. Look for: req.json() or express.json() running before verification, JSON.stringify(...) used as the hashed input, == or === on signatures, a legacy header (x-hub-signature instead of x-hub-signature-256), no timestamp check, no event id dedupe, and secrets that are hardcoded or have a NEXT_PUBLIC_ prefix. Then fix each handler: 1. Read the body with await req.text() before any parsing. 2. Verify the signature. Clerk and Resend: the svix package. GitHub: HMAC SHA-256 of the raw body against x-hub-signature-256, with the sha256= prefix. Lemon Squeezy: hex HMAC SHA-256 against x-signature. Paddle and Stripe: the official SDK. 3. Compare with crypto.timingSafeEqual after checking buffer lengths. 4. Return 400 on failure before any database write. 5. Reject stale timestamps where the provider signs one. 6. Dedupe with a unique-constraint table. Key on svix-id for Svix. For payload-only signatures, key on a SHA-256 of the raw body, never on an unsigned header. 7. Read one server-only secret per endpoint. Output the file and line of every handler you changed, what was wrong, and a short explanation of each change. Add a test that sends a bad signature and asserts nothing is written.
For a second opinion on your repo afterwards, Leakcade can scan it.
Further reading
- GitHub Docs: Validating webhook deliveries
- Svix Docs: How to verify webhooks with the Svix libraries
- Standard Webhooks: the open webhook specification
- Lemon Squeezy Docs: Signing requests
- Node.js docs: crypto.timingSafeEqual(a, b)
FAQ
How do I verify a webhook signature with HMAC SHA-256 in Node?
For providers like GitHub and Lemon Squeezy, read the raw body as a string, compute createHmac("sha256", secret).update(raw).digest("hex") with node:crypto, and compare it to the header with crypto.timingSafeEqual. Check that both buffers have the same length first. GitHub adds a sha256= prefix. For Svix, use its library, because it signs an id and timestamp too.
Why does my webhook signature verification fail in Next.js?
Most often the body was parsed before hashing. In an App Router route handler, use await req.text() and pass that string unchanged to your verifier. Other causes are the wrong secret (each endpoint has its own), the wrong header, or a proxy that modifies the payload.
How do I verify a Svix or Clerk webhook?
Install the svix package, create new Webhook(secret) with your endpoint's whsec_ secret, and call verify(rawBody, headers). It throws on failure, so wrap it in try/catch and return 400. Check Clerk's current docs for where the secret lives.
Do I really need crypto.timingSafeEqual instead of ===?
Yes. A plain == or === can return at the first differing character, so response times can reveal how much of a guessed signature was correct. crypto.timingSafeEqual throws if the buffers differ in length, so check a.length === b.length first. GitHub's docs also say never to use a plain ==.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms