anishfyi.com Projects All writings

April 14, 20267 min readWebhooks, Security, Django

Verifying Inbound Webhooks with HMAC Signatures

Why every inbound webhook needs a signature check, and how to verify HMAC payloads safely in Django.

A webhook endpoint is an open door. Anyone who learns the URL can send it a request, and the URL is not a secret: it travels through logs, dashboards, and third-party configuration screens. HMAC signature verification is how you tell a real delivery from a forged one.

Why a signature, not an IP allowlist

Providers change their sending IP ranges, and IPs can be spoofed in ways that are hard to reason about. A shared secret and a signature prove the payload came from someone who holds the secret, and that the bytes were not changed along the way.

How HMAC verification works

The provider hashes the raw request body together with a secret only the two of you know, and sends the result in a header. You recompute the same hash and compare. If they match, the request is genuine.

Verify against the raw body

This is the detail that trips people up. You must hash the exact bytes that were sent, so read request.body, never the parsed request.data. Re-serializing parsed JSON reorders keys and changes whitespace, and the signature will never match again.

import base64
import hashlib
import hmac

from django.conf import settings


def verify_shopify_webhook(request):
    received = request.headers.get("X-Shopify-Hmac-Sha256", "")
    digest = hmac.new(
        settings.SHOPIFY_WEBHOOK_SECRET.encode(),
        request.body,
        hashlib.sha256,
    ).digest()
    expected = base64.b64encode(digest).decode()
    return hmac.compare_digest(received, expected)

Always use a constant-time comparison

Compare with hmac.compare_digest, not ==. A plain equality check can return early on the first mismatched character, and that timing difference leaks information an attacker can use to guess the signature byte by byte.

Wire it into the view

The endpoint carries no session and no token, so it disables the framework auth classes and relies entirely on the signature.

from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView


class ShopifyOrderWebhook(APIView):
    authentication_classes = []
    permission_classes = []

    def post(self, request, *args, **kwargs):
        if not verify_shopify_webhook(request):
            return Response(status=status.HTTP_401_UNAUTHORIZED)
        process_order_webhook.delay(request.data)
        return Response(status=status.HTTP_200_OK)

Acknowledge fast, process later

The view verifies, pushes the payload to a Celery task, and returns 200 immediately. Most providers retry a webhook if the response is slow, and a retry while the first request is still running means duplicate work. Fast acknowledgement avoids that.

Make the handler idempotent

Webhooks are delivered at least once, which means sometimes more than once. Key the work on the provider event id and skip anything you have already seen. A signature proves who sent the payload. Idempotency keeps a repeat delivery from charging a customer twice.

Working on something in this territory? Book thirty minutes.