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
- Your server creates a Razorpay order for the amount and stores its ID (
order_xyz) on your own order. - The browser opens Razorpay Checkout with that order ID.
- On success, Checkout hands the browser three values:
razorpay_order_id,razorpay_payment_idandrazorpay_signature. - 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:
- Create a cheap order A and pay ₹99 for it.
- Create an expensive order B and close the checkout without paying.
- Send A's genuine
razorpay_order_id,razorpay_payment_idandrazorpay_signaturewith B's internalorder_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:
- The signature is computed over the raw body with the webhook secret, which is a different secret from your API key secret.
- The update is keyed by the Razorpay order ID that Razorpay itself sent, and it only moves
pendingtopaid, so duplicate deliveries do nothing. - The browser flow and the webhook can now race safely. Whichever arrives first marks the order paid, and the other is a no-op.
Checklist
- The amount is set on the server when the Razorpay order is created, never taken from the client.
- The stored
razorpay_order_idis used for verification, not the one in the request. - Signatures are compared with
hmac.compare_digest. - Marking an order as paid is idempotent.
- A
payment.capturedwebhook with its own secret is the source of truth. - There's a reconciliation job for orders stuck in "pending".
Payment bugs rarely come from broken crypto. They come from trusting an input that looked like it was already verified.