The Raw Body Problem: Why You Must Verify Before You Parse
Stripe, GitHub, and every serious provider deliver webhooks the same way: a server you do not control POSTs to a public URL, and the only thing standing between that URL and an attacker who found it in a code repository is a signature. A route handler at app/api/webhooks/stripe/route.ts that returns 200 to anyone who posts has no authentication at all — no session, no cookie, no login form an attacker must defeat. The signature is the credential, and verifying it has one hard requirement that surprises people: the bytes you verify must be the exact bytes that arrived.
await request.json() gives you parsed then re-serializable data, not the received payload — and a signature computed over the original bytes will not match anything you re-serialize (key order, whitespace, and number formatting all drift). So the handler opens with the raw body:
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: NextRequest) {
const raw = await request.text(); // raw bytes as text — BEFORE any parse
const signature = request.headers.get('stripe-signature');
if (!signature) return new NextResponse('Missing signature', { status: 400 });
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
raw, signature, endpointSecret,
undefined, // tolerance in seconds (default 300)
);
} catch (err) {
return new NextResponse(`Invalid signature: ${err.message}`, { status: 400 });
}
// ...handle event.id / event.type
}
constructEventAsync is the async variant required in the Next.js runtime (the sync constructEvent needs a Buffer); it decodes the stripe-signature header, checks the timestamp, and compares the HMAC-SHA256 of `${timestamp}.${rawBody}` against every v1= value in the header. It performs the comparison in constant time internally — do not reimplement it with ===.
The GitHub flavor signs a different envelope but obeys the same rule, and it is the cleanest demonstration of the mechanics: the header carries a hex HMAC of the raw body, keyed with your shared secret, computed with SHA-256:
import { createHmac, timingSafeEqual } from 'node:crypto';
const raw = await request.text();
const expected = 'sha256=' + createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET!)
.update(raw) // the exact received bytes
.digest('hex');
const provided = request.headers.get('x-hub-signature-256') ?? '';
Two placement rules complete the picture. First, do not put this route behind your session middleware: providers are server-to-server clients with no cookies, so a redirect to /login breaks the integration — and requiring cookies would add nothing the signature does not already prove. Second, treat the signing secret as an environment secret rotated independently of your JWT secret; anyone with repo access who sees a hardcoded secret can mint "verified" webhooks forever.
Local development has a dedicated tool for this: Stripe's CLI (stripe listen --forward-to localhost:3000/api/webhooks/stripe) gives you a rotating test secret and replays real events to your machine — including deliberate signature mismatches, which is how you should test the 400 path before shipping. The same discipline applies to any provider: keep a fixture of a validly signed body (with its secret swapped for CI) and assert that tampering with one byte of it flips the response to 400.
Constant-Time Comparison and Why === Is a Forgery Tool
You computed the expected signature. Now you compare it to the one in the header. The obvious code is if (provided === expected) allow(), and it is wrong in a way that takes a paragraph to see — and years to exploit in practice, which is why it keeps shipping.
A JavaScript string comparison exits at the first differing character. An attacker who can measure response times precisely can submit signatures that share one more byte than the previous attempt, watch for the tiny delay, and recover a valid signature one character at a time without ever guessing the whole value. Each request leaks whether the first N bytes matched. Over a public webhook URL with thousands of attempts per minute available, that is a viable attack, not a theoretical one.
The platform answer is crypto.timingSafeEqual, which compares two buffers over their full length regardless of where they differ:
import { timingSafeEqual } from 'node:crypto';
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) return false; // length leaks nothing useful
return timingSafeEqual(bufA, bufB); // full-length comparison
}
export async function POST(request: NextRequest) {
const raw = await request.text();
const provided = request.headers.get('x-hub-signature-256') ?? '';
const expected = 'sha256=' + createHmac('sha256', process.env.WH_SECRET!).update(raw).digest('hex');
if (!safeEqual(provided, expected)) {
return new NextResponse('Bad signature', { status: 400 });
}
return NextResponse.json({ ok: true });
}
Note the length check: timingSafeEqual throws on unequal lengths, and returning false on a length mismatch is safe because the length of a SHA-256 hex digest is public information. The goal is never "leak nothing" — it is "leak nothing about the secret-dependent content."
Two practical footnotes. Encodings must match on both sides: hex against hex, base64 against base64 — a mismatch fails closed with a 400, which is survivable, but a casing difference in a hex digest fails the same way, so normalize case when the provider's spec allows it. And do not log the signature header or your expected value at info level: log pipelines have wide read access, and although one capture cannot defeat a timestamp-checked endpoint indefinitely, it hands an attacker a replay window you would rather not give them.
The timestamp belongs to the same family of protections. A signature alone only proves the payload was produced by the provider at some point — an attacker who captures one valid delivery (via logs, a proxy, or a compromised dependency) can replay it forever unless the signed material binds a time. Stripe solves this by including t=<unix seconds> in the signed string and letting you pass a tolerance to constructEventAsync (default 300 seconds). Reject anything outside that window, and the captured body becomes worthless after five minutes. If you compare the timestamp yourself, remember it is attacker-supplied input: parse it as an integer, bound it, and then run the constant-time comparison — never Date.now() - t < tolerance with an unvalidated string.
Interview framing, stated plainly: the three lines that matter are raw body in, constant-time compare, fresh timestamp. A candidate who says "I verify the Stripe signature" but cannot explain why request.json() first breaks it has only used the library, not understood it.
The Delivery That Arrives Three Times: Idempotency and the 200 Contract
Provider delivery is at-least-once, not exactly-once. Stripe redelivers any event not acknowledged with a 2xx within roughly 72 hours, with exponential backoff, and a GitHub Actions-style burst after an incident can replay thousands of events you already processed. Your handler will eventually see evt_123 more than once — possibly concurrently. The design question is not "how do I prevent duplicates" (you cannot) but "what happens the second time the same event arrives."
The answer is an idempotency key: the provider's event id, stored in a table with a unique constraint, claimed before the side effects run.
// app/api/webhooks/stripe/route.ts
import { after } from 'next/server'; // Next 15.1+; unstable_after in 15.0
export async function POST(request: NextRequest) {
const raw = await request.text();
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
raw, request.headers.get('stripe-signature')!, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new NextResponse('Bad signature', { status: 400 });
}
// Atomic claim: INSERT ... ON CONFLICT DO NOTHING RETURNING id
const [row] = await db
.insert(webhookEvents)
.values({ id: event.id, type: event.type })
.onConflictDoNothing({ target: webhookEvents.id })
.returning({ id: webhookEvents.id });
if (!row) return NextResponse.json({ received: true, duplicate: true }); // seen it
after(processEvent(event)); // heavy work after the response is sent
return NextResponse.json({ received: true });
}
Every piece is load-bearing. The unique constraint (not a check-then-insert, which races between two concurrent deliveries) is what makes the second claim fail; a Redis SET id 1 NX EX 86400 does the same job for a multi-instance deployment without shared storage. The 200 on duplicates tells the provider to stop retrying while your own logic stays a no-op. after() moves slow work — charging a card, calling three APIs — past the response, so provider timeouts do not trigger redelivery of an event you are already handling. In Next 15.1+ it is imported from next/server (it was unstable_after in 15.0); on serverless platforms, anything still running after the response ends is killed, so after() is not optional there.
The acknowledgment contract decides failure behavior, and it is the part most teams get backwards:
| Return | Provider does | Correct for |
|---|---|---|
2xx |
stops retrying | event handled or safely recorded for later |
400 bad signature |
stops (will never succeed) | malformed or forged payloads |
5xx / timeout |
retries with backoff | transient failures you genuinely want replayed |
So a failure inside processEvent after you returned 200 is your queue problem: record the failure against the stored event and retry from your own worker, or restructure so side effects happen inside after() with its own retry policy. Returning 500 from the route is only correct when the work had not started — and even then, understand you have asked for a duplicate delivery, which the idempotency claim now makes safe.
That closes the loop: the signature (chapters one and two) proves who sent it and that it is fresh; the idempotency store proves you have not already acted on it. Together they are the whole discipline of receiving webhooks in a route handler.
Quiz
1. A webhook handler calls await request.json() and then verifies the provider signature against JSON.stringify(body). Verification fails for legitimately signed requests. Why?
2. Why is if (provided === expected) a security bug in a webhook handler?
3. A validly signed webhook payload was captured in a log six months ago. What stops an attacker from resubmitting it?
4. Your processEvent(event) throws a transient database error after the handler already returned 200. What happens next, and what is the correct design?
Flashcards
Question
Why must a webhook handler read the raw body before anything else?
Click to reveal answer
Answer
The signature is computed over the exact received bytes. request.json() parses (and any later re-serialization drifts in formatting/key order), so verification fails or, worse, verifies attacker-rewritten data. Use request.text() for the signature, then JSON.parse separately.
Question
What is a timing attack on signature comparison, and what is the fix?
Click to reveal answer
Answer
Early-exit comparisons (===) leak, via response timing, how many leading bytes matched; an attacker forges a valid signature one character at a time. Fix: crypto.timingSafeEqual on equal-length buffers (a length check first is fine), or a provider helper that already does this.
Question
How does replay protection work for webhooks?
Click to reveal answer
Answer
Two independent layers: (1) the provider signs a timestamp into the payload and you reject deliveries outside a tolerance window (Stripe: 300 s), and (2) an idempotency store keyed by event id — unique constraint or Redis SET NX — makes a second delivery a no-op.
Question
What does the status code you return tell the provider?
Click to reveal answer
Answer
2xx = delivered, stop retrying (used for handled AND for recorded duplicates). 4xx (bad signature) = stop, it will never validate. 5xx/timeout = retry with backoff — only return one when you genuinely want redelivery. After 2xx, failures are your own replay queue's problem.
Question
Why run heavy webhook side effects in after() instead of before returning?
Click to reveal answer
Answer
Providers time out and retry slow deliveries, and serverless platforms kill work still running after the response. after() (from next/server, Next 15.1+; unstable_after in 15.0) acknowledges fast and does the slow work post-response; the idempotency row makes any redelivery safe.
Revision Notes
Key Takeaways
- 1. A webhook URL has no login; the HMAC signature over the exact raw bytes is the only credential — which is why request.text() comes before any parsing
- 2. Constant-time comparison is mandatory: === leaks a matching-prefix oracle through response timing, and crypto.timingSafeEqual is the one-line fix
- 3. Signatures prove origin, not freshness — a timestamp tolerance plus an event-id store are what make a captured payload worthless
- 4. Delivery is at-least-once: an atomic idempotency claim (unique constraint, not check-then-insert) turns redelivery into a no-op
- 5. The status code is a contract with the provider's retry loop; 2xx ends its responsibility and hands remaining failures to your own queue
Interview Tips
- • "How do you verify a Stripe webhook in Next.js?" — request.text() raw body, stripe-signature header, constructEventAsync with the endpoint secret; explain that json()-first breaks byte equality
- • "What is a timing attack?" — early-exit string comparison leaks prefix match length per request; timingSafeEqual fixes it; a length precheck leaks only public digest length
- • "Your handler processed the same charge event twice. Why?" — at-least-once delivery plus missing idempotency claim; the fix is a unique event-id insert/Redis SET NX before side effects
- • "How do you handle slow webhook work on Vercel?" — acknowledge with 2xx quickly and run side effects in after() (or a queue) because post-response work is otherwise killed; retries must be safe via idempotency
Cheat Sheet
Verifying Webhook Signatures
Handler skeleton:
export async function POST(req: NextRequest) {
const raw = await req.text(); // 1. raw bytes first
let event;
try {
event = await stripe.webhooks.constructEventAsync(
raw, req.headers.get('stripe-signature')!, process.env.STRIPE_WEBHOOK_SECRET!);
} catch { return new NextResponse('Bad sig', { status: 400 }); } // 2. verify (constant-time)
const [row] = await db.insert(events).values({ id: event.id })
.onConflictDoNothing().returning(); // 3. atomic idempotency claim
if (!row) return NextResponse.json({ received: true, duplicate: true });
after(processEvent(event)); // 4. slow work after 200
return NextResponse.json({ received: true });
}
Rules: raw body → HMAC verify (never ===; timingSafeEqual or provider helper) → timestamp tolerance (Stripe 300 s default) → event-id claim (UNIQUE / SET NX) → 2xx fast.
Status contract: 2xx stop · 400 stop (bad sig) · 5xx retry-with-backoff. After a 2xx, failures belong to your own retry queue.
Ops: signing secret in env, rotated separately from JWT secret; do not gate the route on session auth (providers send no cookies); duplicate deliveries are normal — design processEvent to be a no-op on repeats.