Secure Webhook Verification
Webhooks are HTTP callbacks that push data to your server. Without verification, anyone can send fake events to your endpoint. This guide covers how to verify webhooks correctly and handle edge cases.
Verify the Signature
Most webhook providers sign the payload with HMAC-SHA256. The signature is sent in a header. Your server must recompute the signature and compare.
Stripe Example
Stripe sends the signature in the Stripe-Signature header:
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: Request) {
const body = await request.text(); // raw body, not parsed JSON
const sig = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, endpointSecret);
} catch (err) {
return new Response("Invalid signature", { status: 400 });
}
// Process event...
return new Response("OK", { status: 200 });
}
Reference: Stripe — Check Webhook Signatures
GitHub Example
GitHub sends the signature in X-Hub-Signature-256:
import crypto from "crypto";
function verifyGitHubWebhook(
payload: string,
signature: string,
secret: string
): boolean {
const expected = "sha256=" +
crypto.createHmac("sha256", secret).update(payload).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
Reference: GitHub — Validating Webhook Deliveries
Generic HMAC Verification
For any provider that uses HMAC-SHA256:
import crypto from "crypto";
function verifyHmacSignature(
payload: string,
receivedSignature: string,
secret: string
): boolean {
const computed = crypto
.createHmac("sha256", secret)
.update(payload, "utf8")
.digest("hex");
// Timing-safe comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(computed)
);
}
Use the Raw Body
Signature verification requires the exact bytes the provider signed. If your framework parses the body (JSON.parse), the re-serialized version may differ.
In Next.js App Router:
const body = await request.text(); // raw string
In Express:
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const body = req.body; // Buffer
// verify signature using body
});
Do not use express.json() for webhook endpoints.
Use Timing-Safe Comparison
Always use crypto.timingSafeEqual() to compare signatures. Regular string comparison (===) leaks information about which bytes match through timing differences.
// BAD — vulnerable to timing attacks
if (computedSig === receivedSig) { ... }
// GOOD
crypto.timingSafeEqual(Buffer.from(computedSig), Buffer.from(receivedSig));
Reference: Node.js — crypto.timingSafeEqual
Prevent Replay Attacks
An attacker who captures a valid webhook can replay it later. Defenses:
Timestamp Validation
Most providers include a timestamp in the request. Reject requests older than a threshold (e.g., 5 minutes):
const timestamp = parseInt(request.headers.get("x-webhook-timestamp")!);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
return new Response("Request too old", { status: 400 });
}
Stripe includes the timestamp in the Stripe-Signature header (the t= parameter). Their SDK validates this automatically.
Event ID Deduplication
Store processed event IDs and reject duplicates:
const eventId = event.id;
// Check if already processed
const exists = await db.webhookEvent.findUnique({ where: { eventId } });
if (exists) {
return new Response("Already processed", { status: 200 });
}
// Process and record
await db.webhookEvent.create({ data: { eventId, processedAt: new Date() } });
Clean up old records periodically (TTL matching your replay window).
Implement Idempotency
Webhook providers retry on failure. Your handler must produce the same result whether called once or five times:
- Use the event ID as an idempotency key.
- Make database operations idempotent (upserts, conditional updates).
- Return 200 after processing, even on retries.
// Idempotent: upsert based on external event ID
await db.order.upsert({
where: { stripePaymentId: event.data.object.id },
create: { ... },
update: { ... },
});
Return the Right Status Code
| Status | Meaning | Provider Action |
|---|---|---|
| 200-299 | Success | No retry |
| 400 | Bad request (invalid signature) | No retry (most providers) |
| 500 | Server error | Retry later |
| Timeout | No response | Retry later |
Return 200 quickly. If you need to do slow processing, queue the event and process asynchronously.
Secure the Endpoint
- IP allowlisting: if the provider publishes their IP ranges, restrict access: Stripe — Webhook IP Addresses
- HTTPS only: never accept webhooks over HTTP.
- No authentication bypass: do not skip signature verification for "testing."
Checklist
- Signature verified using HMAC with timing-safe comparison
- Raw body used (not re-serialized JSON)
- Timestamp validated (reject old requests)
- Event IDs deduplicated
- Handler is idempotent
- Returns 200 quickly (async processing for slow tasks)
- HTTPS only
- Webhook secret stored securely (not in code)
Related Guides
Content is AI-assisted and reviewed by our team, but issues may be missed and best practices evolve rapidly, send corrections to [email protected]. Always consult official documentation and validate key implementation decisions before making design or security choices.
