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-AtandX-Shopify-Webhook-Idaren’t part of it. Shopify’s docs suggest skipping repeatedX-Shopify-Webhook-Idvalues, 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.