Webhooks Are Trust Boundaries
Your Stripe webhook fires when a payment succeeds. Your GitHub webhook fires on every push. Anyone who can send an HTTP POST to your endpoint can fake these events -- unless you verify the signature.
Here's how to properly validate webhooks from Stripe, GitHub, and custom sources.
Stripe Webhook Verification
// 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!)
export async function POST(req: NextRequest) {
const body = await req.text() // Raw body REQUIRED for signature
const signature = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
// Safe to process
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object)
break
case 'customer.subscription.deleted':
await handleSubscriptionCancelled(event.data.object)
break
}
return NextResponse.json({ received: true })
}
// CRITICAL: disable body parsing so raw body is available
export const config = {
api: { bodyParser: false } // For Pages Router
}
// App Router uses req.text() -- body parsing isn't an issue
GitHub Webhook Verification
// app/api/webhooks/github/route.ts
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
function verifyGitHubSignature(payload: string, signature: string, secret: string): boolean {
const expected = `sha256=${crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex')}`
// Timing-safe comparison prevents timing attacks
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
)
}
export async function POST(req: NextRequest) {
const body = await req.text()
const signature = req.headers.get('x-hub-signature-256') ?? ''
const event = req.headers.get('x-github-event')
if (!verifyGitHubSignature(body, signature, process.env.GITHUB_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
const payload = JSON.parse(body)
if (event === 'push') {
await triggerDeploy(payload.ref, payload.commits)
}
return NextResponse.json({ ok: true })
}
Generic HMAC Webhook Verifier
Reusable for any webhook provider using HMAC-SHA256:
// lib/verify-webhook.ts
import crypto from 'crypto'
export function verifyWebhookSignature({
payload,
signature,
secret,
algorithm = 'sha256',
prefix = 'sha256=',
}: {
payload: string
signature: string
secret: string
algorithm?: string
prefix?: string
}): boolean {
const expectedSig = `${prefix}${crypto
.createHmac(algorithm, secret)
.update(payload)
.digest('hex')}`
if (expectedSig.length !== signature.length) return false
return crypto.timingSafeEqual(
Buffer.from(expectedSig),
Buffer.from(signature)
)
}
Replay Attack Prevention
A valid signature doesn't mean a fresh request. Stripe includes a timestamp; validate it.
function verifyTimestamp(timestamp: number, toleranceSeconds = 300): boolean {
const now = Math.floor(Date.now() / 1000)
return Math.abs(now - timestamp) <= toleranceSeconds
}
// Stripe's constructEvent does this automatically (5-minute window)
// For custom webhooks:
const timestamp = parseInt(req.headers.get('x-webhook-timestamp') ?? '0')
if (!verifyTimestamp(timestamp)) {
return NextResponse.json({ error: 'Request expired' }, { status: 400 })
}
Idempotency: Handle Duplicate Deliveries
Webhook providers retry on failure. Your handler must be idempotent.
// Track processed webhook IDs
export async function POST(req: NextRequest) {
const event = stripe.webhooks.constructEvent(...)
const eventId = event.id
// Check if already processed
const existing = await db.processedWebhook.findUnique({ where: { eventId } })
if (existing) {
return NextResponse.json({ received: true, duplicate: true })
}
// Process and mark as done
await db.$transaction(async (tx) => {
await handleEvent(event, tx)
await tx.processedWebhook.create({ data: { eventId } })
})
return NextResponse.json({ received: true })
}
MCP Servers and Webhooks
MCP servers often receive webhook-like callbacks from external services. Unverified webhook handlers in MCP servers are a common vulnerability -- they allow arbitrary event injection into your AI agent's workflow.
The MCP Security Scanner checks for missing signature verification in MCP webhook handlers. $29/mo -- scan any server in 60 seconds.
Top comments (0)