Verifying Razorpay payments: the order ID you shouldn't trust

· 4 min read

Razorpay's checkout flow ends with your server checking a signature. Most tutorials show that check in a few lines, and the check itself is correct. But I recently reviewed a store backend before launch where the check passed and still let one payment mark a different order as paid. The bug wasn't in the cryptography. It was in where one input came from.

How the flow works

  1. Your server creates a Razorpay order for the amount and stores its ID (order_xyz) on your own order.
  2. The browser opens Razorpay Checkout with that order ID.
  3. On success, Checkout hands the browser three values: razorpay_order_id, razorpay_payment_id and razorpay_signature.
  4. The browser sends them to your server, which checks that the signature is HMAC-SHA256(order_id + "|" + payment_id, key_secret).

Only Razorpay and your server know key_secret, so a valid signature proves that Razorpay processed that payment for that Razorpay order.

The bug

The endpoint looked roughly like this:

def verify_payment(request):
    order = Order.objects.get(id=request.data["order_id"], user=request.user)
    rzp_order_id = request.data["razorpay_order_id"]      # from the client
    rzp_payment_id = request.data["razorpay_payment_id"]
    signature = request.data["razorpay_signature"]

    expected = hmac.new(secret, f"{rzp_order_id}|{rzp_payment_id}".encode(),
                        hashlib.sha256).hexdigest()
    if expected != signature:
        return error("Payment verification failed")

    order.payment_status = "paid"
    order.save()

Every Razorpay value is checked, but nothing ties them to this order. The internal order_id and the Razorpay order ID both come from the client, and nothing checks that they belong together.

So a buyer can:

  1. Create a cheap order A and pay ₹99 for it.
  2. Create an expensive order B and close the checkout without paying.
  3. Send A's genuine razorpay_order_id, razorpay_payment_id and razorpay_signature with B's internal order_id.

The signature is genuine, so it verifies, and order B is marked as paid.

The fix

Verify against the Razorpay order ID you stored when you created the order, not one the client sends. Compare in constant time, and make the update idempotent:

import hashlib
import hmac

def verify_payment(request):
    order = Order.objects.select_for_update().get(
        id=request.data["order_id"], user=request.user
    )
    if order.payment_status == "paid":
        return ok(order)  # idempotent: retries and double-clicks are fine

    payment_id = request.data.get("razorpay_payment_id", "")
    signature = request.data.get("razorpay_signature", "")
    expected = hmac.new(
        settings.RAZORPAY_KEY_SECRET.encode(),
        f"{order.razorpay_order_id}|{payment_id}".encode(),  # stored, not sent
        hashlib.sha256,
    ).hexdigest()

    if not order.razorpay_order_id or not hmac.compare_digest(expected, signature):
        return error("Payment verification failed")

    order.payment_status = "paid"
    order.razorpay_payment_id = payment_id
    order.save(update_fields=["payment_status", "razorpay_payment_id"])
    return ok(order)

(Run this inside transaction.atomic() so select_for_update() actually holds the row lock.)

If you use the official SDK, client.utility.verify_payment_signature(...) does the HMAC for you. Just pass it your stored razorpay_order_id.

Don't rely on the browser at all: add the webhook

Even a correct verify endpoint depends on the buyer's browser getting back to your site. People close tabs, networks drop, and phones lock mid-redirect. The money arrives, but the order stays "pending".

Razorpay webhooks fix that. Subscribe to payment.captured (or order.paid) and treat the webhook as the source of truth:

def razorpay_webhook(request):
    body = request.body  # raw bytes, before any JSON parsing
    received = request.headers.get("X-Razorpay-Signature", "")
    expected = hmac.new(settings.RAZORPAY_WEBHOOK_SECRET.encode(), body,
                        hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, received):
        return HttpResponse(status=400)

    event = json.loads(body)
    if event["event"] == "payment.captured":
        payment = event["payload"]["payment"]["entity"]
        Order.objects.filter(
            razorpay_order_id=payment["order_id"], payment_status="pending"
        ).update(payment_status="paid", razorpay_payment_id=payment["id"])
    return HttpResponse(status=200)

Note three things:

Checklist

Payment bugs rarely come from broken crypto. They come from trusting an input that looked like it was already verified.