Shopify Webhook HMAC Verification Fix

September 24, 2026 · 1 views
Shopify Webhook HMAC Verification Fix

Your Shopify app registers a webhook, Shopify starts sending orders/create or app/uninstalled events, and every single request gets rejected with a 401 before your handler even runs. The culprit is almost always the same: your Shopify webhook HMAC verification is comparing a hash computed from the wrong version of the request body. This happens even when your signing secret is correct, which is what makes it so confusing to debug.

Shopify signs every webhook payload with your app's client secret (or webhook-specific secret) and sends the result in the X-Shopify-Hmac-SHA256 header. Your server is supposed to compute the same HMAC-SHA256 digest over the exact raw bytes of the request body and compare it, using a constant-time comparison, against that header. The verification fails almost every time because something in the request pipeline — a body parser, a proxy, a logging middleware — touches the body before you get a chance to hash it in its original form.

Why HMAC verification actually fails

Three causes account for the overwhelming majority of these failures:

  1. The body was parsed to JSON before you hashed it. Express's express.json() (or Laravel's automatic request parsing) reads the stream, decodes it, and by the time your handler runs, the original byte sequence is gone. Re-serializing the parsed object with JSON.stringify() almost never reproduces Shopify's exact byte layout — key order, whitespace, and Unicode escaping can all differ slightly, which is enough to break a cryptographic hash comparison.
  2. You're using the wrong secret. Webhooks created through the Admin API or a custom app use your app's API secret key, but if you're using Shopify's newer per-webhook secrets or testing against a different app/store than you think, the digest will never match.
  3. You're comparing strings unsafely or decoding incorrectly. The header value is base64-encoded; your computed digest needs to be base64-encoded too before comparison, not compared as a raw hex digest.

Fixing it in Node.js and Express

The fix is to capture the raw request body before any JSON parsing happens, and verify against that raw buffer specifically for the webhook route.

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

// Capture raw body ONLY for the webhook route, before json parsing
app.use('/webhooks', express.raw({ type: 'application/json' }));

function verifyShopifyWebhook(req, res, next) {
  const hmacHeader = req.get('X-Shopify-Hmac-SHA256');
  const secret = process.env.SHOPIFY_API_SECRET;

  const digest = crypto
    .createHmac('sha256', secret)
    .update(req.body) // req.body is the raw Buffer here, not parsed JSON
    .digest('base64');

  const isValid = hmacHeader &&
    crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(hmacHeader));

  if (!isValid) {
    return res.status(401).send('Webhook verification failed');
  }

  req.body = JSON.parse(req.body.toString('utf8')); // safe to parse now
  next();
}

app.post('/webhooks/orders-create', verifyShopifyWebhook, (req, res) => {
  console.log('Order created:', req.body.id);
  res.status(200).send('OK');
});

app.listen(3000);

The key detail is express.raw({ type: 'application/json' }) scoped to only the /webhooks path, so your other routes can still use express.json() normally. If you apply express.json() globally first, the raw bytes are already lost by the time this middleware runs, and no amount of secret-checking will fix that.

Fixing it in Laravel/PHP

Laravel's global middleware and form-request validation can similarly consume and normalize the body. Verify against $request->getContent(), which returns the raw request body, inside a route that bypasses CSRF and doesn't rely on auto-parsed input for the comparison:

Route::post('/webhooks/orders-create', function (Request $request) {
    $hmacHeader = $request->header('X-Shopify-Hmac-Sha256');
    $rawBody = $request->getContent();
    $secret = config('services.shopify.secret');

    $calculatedHmac = base64_encode(
        hash_hmac('sha256', $rawBody, $secret, true)
    );

    if (!hash_equals($calculatedHmac, $hmacHeader)) {
        abort(401, 'Webhook verification failed');
    }

    $payload = json_decode($rawBody, true);
    // process $payload...

    return response('OK', 200);
})->withoutMiddleware([VerifyCsrfToken::class]);

hash_equals() is important here — it performs a constant-time comparison, which prevents timing attacks against your webhook endpoint, the same reason Node's example uses crypto.timingSafeEqual() instead of ===.

Common mistakes to check first

  • Comparing the digest as hex instead of base64 — Shopify's header is base64-encoded, so digest('hex') will never match.
  • Applying a global body parser before the webhook route, which destroys the raw bytes.
  • Using the wrong secret for the environment — a staging app secret against a production webhook, or vice versa.
  • Trimming or re-encoding the raw body (some frameworks normalize line endings or encoding automatically) — any byte-level change breaks the hash.
  • Testing with a manually crafted request instead of Shopify's actual webhook delivery, which can hide byte-encoding differences that only show up with real traffic.

Frequently Asked Questions

Why does my webhook work when I test it manually with Postman but fail from Shopify? Postman lets you control the raw body directly, so it never gets mangled by your own middleware in the same order as a real incoming request. Real Shopify traffic goes through your full middleware stack, which is usually where the raw body gets lost. Test with Shopify's actual webhook delivery or its CLI trigger command, not a hand-built request.

Can I verify the HMAC after parsing the JSON body? No, not reliably. Once the body has been parsed and would need to be re-serialized to compare, you're hashing a reconstruction of the payload, not the exact bytes Shopify sent. Any difference in key order, spacing, or number formatting breaks the comparison.

Does this apply to both app webhooks and Shopify Flow webhooks? Yes. Any webhook signed with X-Shopify-Hmac-SHA256 — whether created via the Admin API, the shopify_app config, or Shopify Flow's custom action — needs the same raw-body verification approach.

Is there a way to debug which secret Shopify actually used? Not directly, but you can log the computed digest and the received header side by side in a non-production environment and confirm they diverge even when the secret is correct — that tells you the raw body is the problem, not the secret. If they diverge when you're certain the raw body is intact, double-check you're using the API secret key from the correct app in the Partner Dashboard.

Key takeaways

Shopify webhook HMAC verification failures almost always come down to hashing the wrong version of the request body, not an incorrect secret. Capture the raw, unparsed body specifically for your webhook routes — express.raw() in Node, $request->getContent() in Laravel — compute the HMAC-SHA256 digest as base64, and compare it with a constant-time function like timingSafeEqual or hash_equals. Keep that raw-body capture scoped only to webhook endpoints so the rest of your application can keep using normal JSON parsing without disruption.

#nodejs #laravel #api-security #shopify #webhooks #hmac
Share this article:

0 Comments

No comments yet — be the first to share your thoughts.

Leave a comment

Never published.