Verify a Shopify webhook HMAC

base64(HMAC-SHA256(client secret, raw body))

X-Shopify-Hmac-Sha256 is the HMAC-SHA256 of the raw request body, keyed with your app’s client secret and written in standard base64, not hex. There’s no prefix and no timestamp: compute the same value over the bytes you received and compare it with the header in constant time.

const crypto = require('node:crypto');

const body = '{"id":820982911946154508,"email":"[email protected]","total_price":"199.00"}';
crypto.createHmac('sha256', 'quackyard-demo-client-secret').update(body).digest('base64');
// 'OjKKmXXWxt/1kTvRMUf7kFlJQJIJio7UTE10f+/0qfk='

The tool below shows the same value with Written as set to Base64. Unlike hex, base64 is case-sensitive, so don’t lowercase either side.

Verify it in Node

function verifyShopify(secret, rawBody, header) {
  const expected = Buffer.from(crypto.createHmac('sha256', secret).update(rawBody).digest('base64'));
  const received = Buffer.from(header ?? '');
  // timingSafeEqual throws on different lengths, so check them first.
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

Shopify’s own example decodes both sides from base64 and hands them straight to crypto.timingSafeEqual. A genuine delivery passes, but a short header throws RangeError: Input buffers must have the same byte length and a missing one throws a TypeError from Buffer.from(undefined, 'base64'). Either way the route answers 500 instead of 401. The version above returns false for both.

Express: parsing changes the bytes

Take the body raw on this route. With express.json(), the classic fix of JSON.stringify(req.body) fails on this very payload: JSON.parse turns 820982911946154508, which is above 2^53, into 820982911946154500. The re-serialized body signs to YbNkT/uh…, and nothing matches.

const express = require('express');
const app = express();

app.post('/webhooks/shopify', express.raw({ type: 'application/json' }), (req, res) => {
  if (!Buffer.isBuffer(req.body) || !verifyShopify(process.env.SHOPIFY_CLIENT_SECRET, req.body, req.get('X-Shopify-Hmac-Sha256'))) {
    return res.sendStatus(401);
  }
  const order = JSON.parse(req.body);
  res.send(`ok ${req.get('X-Shopify-Topic')} ${order.id}`);
});

The HMAC passes because it covers the bytes, but order.id still prints 820982911946154500. To keep such IDs whole, parse with a reviver that reads the source text (it works in Node 22): JSON.parse(text, (key, value, { source }) => (Number.isSafeInteger(value) || !/^-?\d+$/.test(source ?? '') ? value : BigInt(source))). If the app runs express.json() for every route, keep the bytes with express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }) and verify req.rawBody.

Python: Flask and FastAPI

import base64
import hashlib
import hmac
import json
import os

from fastapi import FastAPI, HTTPException, Request
from flask import Flask, abort, request

SECRET = os.environ["SHOPIFY_CLIENT_SECRET"].encode()

def verify_shopify(secret: bytes, raw_body: bytes, header: str | None) -> bool:
    expected = base64.b64encode(hmac.new(secret, raw_body, hashlib.sha256).digest())
    return hmac.compare_digest(expected, (header or "").encode())

app = Flask(__name__)

@app.post("/webhooks/shopify")
def shopify():
    if not verify_shopify(SECRET, request.get_data(), request.headers.get("X-Shopify-Hmac-Sha256")):
        abort(401)
    return f"ok {request.get_json()['id']}"

api = FastAPI()

@api.post("/webhooks/shopify")
async def shopify_fastapi(request: Request):
    raw = await request.body()
    if not verify_shopify(SECRET, raw, request.headers.get("X-Shopify-Hmac-Sha256")):
        raise HTTPException(status_code=401)
    return {"ok": json.loads(raw)["id"]}

Python’s json keeps the ID exact, but json.dumps writes {"id": 820982911946154508, "email": …} with a space after every : and ,, so re-serializing fails here too. hmac.compare_digest returns False on a length mismatch by itself.

What the HMAC doesn’t cover

  • Time. Shopify signs the body alone. X-Shopify-Triggered-At and X-Shopify-Webhook-Id aren’t part of it. Shopify’s docs suggest skipping repeated X-Shopify-Webhook-Id values, which catches Shopify’s own duplicates, but an attacker replaying a captured delivery can change an unsigned header. Make handlers idempotent.
  • Secret rotation. After you rotate the client secret, Shopify’s docs say it can take up to an hour before deliveries are signed with the new one, so accept either secret for that hour.
  • Other transports. Only HTTPS deliveries carry the header. Google Cloud Pub/Sub and Amazon EventBridge deliveries don’t need it.

Try it in the HMAC Generator

Signed as the UTF-8 bytes of what's pasted, spaces included, with every line break written as the one chosen. This box keeps line breaks as LF whatever was pasted, so pick CRLF for a body sent with CRLF line endings.

The key never leaves your browser: the signature is worked out here in the page, and nothing is sent anywhere.

Algorithm
Written as
Signature · SHA-256

OjKKmXXWxt/1kTvRMUf7kFlJQJIJio7UTE10f+/0qfk=

74 bytes of message, 28 of key.

Matches — this message, signed with this key.

How webhook signatures are checked

Both services below sign exactly the way this tool does, so the value after the prefix is an HMAC you can check here.

ServiceWhat it sendsWhat the signature is made of
GitHubX-Hub-Signature-256: sha256=<hex>HMAC-SHA256 of the raw request body, keyed with the webhook secret.
StripeStripe-Signature: t=<time>,v1=<hex>HMAC-SHA256 of <time>.<raw body>, keyed with the signing secret, whsec_… and all.

GitHub: paste the raw body as the message, the secret as the key, and what follows sha256= into Check a signature. Stripe: sign t.body — the timestamp from the header, a dot, then the raw body — with the whole signing secret as the key, and compare against v1=.

Runs in your browser. Nothing you enter is sent anywhere.