DEV Community

Cover image for How to Verify Shopify Webhooks Correctly (And Stop Getting It Wrong)
Muhammad Masad Ashraf
Muhammad Masad Ashraf

Posted on • Originally published at kolachitech.com

How to Verify Shopify Webhooks Correctly (And Stop Getting It Wrong)

Originally published on KolachiTech


I have reviewed a lot of Shopify app codebases. One thing shows up constantly: webhook verification that looks correct but silently fails. The developer gets frustrated, removes the check, ships without it, and ships a vulnerability instead.

This post covers how Shopify webhook verification actually works, the implementation mistakes that trip people up, and what a production-ready setup looks like. I will share working Node.js and Python code you can drop in today.


Why Your Webhook Endpoint Is a Target

Your Shopify webhook URL is just an HTTP endpoint. It does not require authentication to reach. Anyone who knows it can POST to it.

Without verification, an attacker can:

  • Fake order fulfillment events
  • Inject customer records
  • Trigger inventory changes
  • Flood your endpoint until it falls over

This matters more than you think. If your webhooks feed an ERP, a CRM, or a fulfillment warehouse, one forged payload corrupts data in every downstream system simultaneously.

Verification is not optional. It is the lock on the front door.


What Shopify Actually Does

Shopify uses HMAC-SHA256 to sign every webhook request it sends.

Here is the exact flow:

  1. Shopify takes the raw bytes of your request body
  2. It runs HMAC-SHA256 using your app's shared secret as the key
  3. It Base64-encodes the result
  4. It drops that value into the X-Shopify-Hmac-SHA256 request header
  5. It sends the request to your endpoint

Your job is to repeat steps 1 through 3 on your side and compare what you compute with what Shopify sent.

If they match: authentic. If they differ: reject immediately, do not process.


The Node.js Implementation

const crypto = require('crypto');

function verifyShopifyWebhook(rawBody, hmacHeader, secret) {
  const generatedHmac = crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('base64');

  const hmacBuffer = Buffer.from(hmacHeader, 'base64');
  const generatedBuffer = Buffer.from(generatedHmac, 'base64');

  // Length check first to avoid exceptions in timingSafeEqual
  if (hmacBuffer.length !== generatedBuffer.length) {
    return false;
  }

  // Constant-time comparison — never use ===
  return crypto.timingSafeEqual(hmacBuffer, generatedBuffer);
}
Enter fullscreen mode Exit fullscreen mode

Express.js setup that actually works:

app.post(
  '/webhooks/shopify',
  express.raw({ type: 'application/json' }), // Buffer raw body BEFORE any parsing
  (req, res) => {
    const hmac = req.headers['x-shopify-hmac-sha256'];
    const secret = process.env.SHOPIFY_WEBHOOK_SECRET;

    if (!verifyShopifyWebhook(req.body, hmac, secret)) {
      return res.status(401).send('Unauthorized');
    }

    // Safe to parse now
    const payload = JSON.parse(req.body);

    // Acknowledge immediately, process async
    res.status(200).send('OK');
    processWebhookAsync(payload);
  }
);
Enter fullscreen mode Exit fullscreen mode

The Python Implementation

import hmac
import hashlib
import base64

def verify_shopify_webhook(raw_body: bytes, hmac_header: str, secret: str) -> bool:
    digest = hmac.new(
        secret.encode('utf-8'),
        raw_body,
        digestmod=hashlib.sha256
    ).digest()
    computed_hmac = base64.b64encode(digest).decode('utf-8')

    # Constant-time comparison
    return hmac.compare_digest(computed_hmac, hmac_header)
Enter fullscreen mode Exit fullscreen mode

Django/Flask usage:

@app.route('/webhooks/shopify', methods=['POST'])
def shopify_webhook():
    raw_body = request.get_data()  # Raw bytes, not request.json
    hmac_header = request.headers.get('X-Shopify-Hmac-SHA256', '')
    secret = os.environ.get('SHOPIFY_WEBHOOK_SECRET')

    if not verify_shopify_webhook(raw_body, hmac_header, secret):
        return 'Unauthorized', 401

    payload = request.json
    return 'OK', 200
Enter fullscreen mode Exit fullscreen mode

The Mistakes That Break Verification (And Ship Vulnerabilities)

Mistake 1: Parsing before verifying

This is the most common one. Your body parsing middleware runs before your verification code and transforms the raw bytes. Your computed HMAC no longer matches what Shopify signed.

The developer sees 401 everywhere. They remove verification. The app ships insecure.

Fix: Use express.raw() on your webhook route specifically, not express.json().

Mistake 2: Using === for comparison

// WRONG — leaks timing information
if (generatedHmac === hmacHeader) { ... }

// RIGHT — constant-time
crypto.timingSafeEqual(hmacBuffer, generatedBuffer);
Enter fullscreen mode Exit fullscreen mode

An attacker can measure microsecond differences in how long === takes to compare strings and deduce the correct signature byte by byte. This is a real attack class called a timing side-channel.

Mistake 3: No replay protection

HMAC proves the payload is authentic. It does not prove it is fresh.

A payload from yesterday is still cryptographically valid today. An attacker who intercepts a legitimate webhook can replay it as many times as they want.

Fix: Validate the timestamp in the payload. Reject anything older than 5 minutes. Store event IDs and reject duplicates.

function isReplayAttack(payload) {
  const eventTime = new Date(payload.created_at).getTime();
  const now = Date.now();
  const fiveMinutes = 5 * 60 * 1000;

  return (now - eventTime) > fiveMinutes;
}
Enter fullscreen mode Exit fullscreen mode

Mistake 4: No shop domain validation

All stores using your app share the same signing secret. A valid webhook from store A is also cryptographically valid when replayed against your store B handler.

Fix: Always validate X-Shopify-Shop-Domain against your database of registered shops.

const shopDomain = req.headers['x-shopify-shop-domain'];

if (!registeredShops.includes(shopDomain)) {
  return res.status(403).send('Forbidden');
}
Enter fullscreen mode Exit fullscreen mode

Mistake 5: Storing the secret in code

# Never do this
SHOPIFY_WEBHOOK_SECRET = "shpss_abc123"  # Committed to git

# Do this
process.env.SHOPIFY_WEBHOOK_SECRET  # Environment variable or secrets manager
Enter fullscreen mode Exit fullscreen mode

Logs are often less locked down than your codebase. Never log the secret either.


The Production Checklist

Before shipping any webhook consumer:

Transport:

  • HTTPS only, no HTTP fallback
  • TLS 1.2 or higher

Verification:

  • Raw body buffered before any parsing middleware
  • HMAC computed with correct shared secret
  • Constant-time comparison used
  • Failed verification returns 401/403 immediately

Replay protection:

  • Timestamp validated, reject payloads older than 5 minutes
  • Event IDs deduplicated (Redis or DB)
  • Shop domain validated against allowed list

Secrets:

  • Secret in environment variable or secrets manager
  • Secret rotation procedure documented and tested
  • Verification failures logged (not the secret, the failure)

Reliability:

  • Handler returns 200 immediately, processes async
  • Dead letter queue configured for processing failures

Why Acknowledging Immediately Matters

Shopify expects a 2xx response within 5 seconds. If you do slow processing synchronously, you will hit timeouts, and Shopify will retry the webhook. Retries without idempotency controls mean double-processing.

The pattern is: verify, acknowledge with 200, hand off to a queue or async worker, process there.

res.status(200).send('OK');         // Shopify gets its answer
processWebhookAsync(payload);       // You do the actual work
Enter fullscreen mode Exit fullscreen mode

What to Read Next

If you are building a serious Shopify integration, verification is just the first layer. These are worth reading after this:


Webhook verification is a two-hour implementation that prevents a category of attacks permanently. Do it right once. The checklist above covers everything you need to ship it confidently.

Top comments (0)