DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Webhook Security in Next.js: Signature Verification, Replay Prevention, and Idempotency

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
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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)
  )
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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)