# Webhook Security: Verifying Razorpay Signatures the Right Way
By Govind Bajaj · 2026

Tags: security, webhooks, razorpay, backend, api-security
A client lost Rs 47,000 to a webhook spoofing attack. The attacker sent fake payment events. The application trusted them. Here's how to verify webhooks correctly.
A client lost forty-seven thousand rupees to a webhook spoofing attack last year.

The attacker discovered their webhook endpoint and sent fake payment.captured events. The application processed them as legitimate and fulfilled orders that were never paid for. The root cause: the webhook handler did not verify the signature.

Webhook security is not optional. Every webhook you receive must be cryptographically verified before you act on it. Here is the correct implementation for Razorpay and the general pattern that applies to every payment provider.

## How Razorpay Webhook Signatures Work

When Razorpay sends a webhook, it includes a signature header computed from the request body and a shared secret. You recompute the signature on your server using the same secret. If the signatures match, the webhook is authentic.

The signature is computed using HMAC-SHA256 of the webhook body with your webhook secret as the key.

## The Correct Implementation

```ts
// app/api/webhooks/razorpay/route.ts
import { NextRequest } from 'next/server'
import { createHmac } from 'crypto'

const WEBHOOK_SECRET = process.env.RAZORPAY_WEBHOOK_SECRET!

function verifySignature(body: string, signature: string): boolean {
  const expected = createHmac('sha256', WEBHOOK_SECRET)
    .update(body)
    .digest('hex')

  // Constant-time comparison to prevent timing attacks
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    )
  } catch {
    return false
  }
}

export async function POST(req: NextRequest) {
  const body = await req.text() // Must use .text(), not .json()
  const signature = req.headers.get('x-razorpay-signature')

  if (!signature) {
    return Response.json({ error: 'Missing signature' }, { status: 400 })
  }

  if (!verifySignature(body, signature)) {
    return Response.json({ error: 'Invalid signature' }, { status: 401 })
  }

  const event = JSON.parse(body)

  // Only now process the event
  switch (event.event) {
    case 'payment.captured':
      await fulfillOrder(event.payload.payment.entity)
      break
    case 'payment.failed':
      await cancelOrder(event.payload.payment.entity)
      break
    case 'refund.processed':
      await processRefund(event.payload.refund.entity)
      break
  }

  return Response.json({ received: true })
}
```

## Critical Implementation Details

Use req.text() not req.json(). JSON parsing can change whitespace or key ordering, which would invalidate the signature. You must verify the exact bytes Razorpay sent.

Use timingSafeEqual, not ===. Regular string comparison returns early on the first mismatch. Attackers can measure response times to guess the signature one character at a time. timingSafeEqual always takes the same time regardless of where the strings differ.

Return 401 on invalid signatures, not 500. A 500 suggests a server error and might trigger retry logic. 401 clearly indicates authentication failure.

## The General Pattern for Any Provider

This pattern applies to Stripe, PayPal, GitHub webhooks, or any signed webhook:

1. Extract the signature from headers
2. Read the raw request body as text or buffer
3. Recompute the signature using your shared secret
4. Compare using constant-time comparison
5. Only after verification succeeds, parse and process the event
6. Always return 200 for processed webhooks (prevents retries)

## Additional Security Measures

IP allowlisting: Razorpay publishes their webhook IP ranges. Restrict your endpoint to accept requests only from these IPs at the firewall or middleware level.

Idempotency: Webhooks can be retried. Every event has a unique ID. Track processed event IDs and skip duplicates.

```ts
// Prevent duplicate processing
const eventId = event.payload.payment.entity.id
const alreadyProcessed = await ProcessedEvent.findOne({ eventId })
if (alreadyProcessed) {
  return Response.json({ received: true }) // Already handled
}

await fulfillOrder(event.payload.payment.entity)
await ProcessedEvent.create({ eventId, processedAt: new Date() })
```

## Takeaways

- Always verify webhook signatures cryptographically before processing
- Use timingSafeEqual for signature comparison — prevents timing attacks
- Read the raw body with req.text(), not req.json() — whitespace matters
- Return 401 for invalid signatures, not 500
- Implement idempotency checks to handle webhook retries safely
- IP allowlist webhook endpoints where the provider publishes IP ranges
- Log all webhook events (valid and invalid) for audit and debugging
- Webhook spoofing is a real attack — the Rs 47,000 loss was preventable