iToverDose/Software· 5 JUNE 2026 · 20:05

Secure Shopify Webhooks: How to Verify Payloads Properly

Many developers skip critical steps when verifying Shopify webhooks. This guide reveals common pitfalls and provides working code to lock down your endpoints against forged payloads.

DEV Community4 min read0 Comments

Implementing Shopify webhooks correctly isn’t just about receiving data—it’s about verifying authenticity. A single verification mistake can expose your systems to data corruption or denial-of-service attacks. Here’s how to get it right.

Why unverified webhooks become attack vectors

Shopify webhook URLs are public endpoints. Without verification, they become open invitations for malicious actors to inject fake data or overload your systems. Attackers can forge order events, manipulate customer records, or trigger inventory changes that ripple through connected ERP, CRM, or warehouse systems.

Consider this: if your webhook feeds a downstream system handling order fulfillment, a single forged payload could cause shipments to the wrong addresses or duplicate orders that strain inventory. Verification isn’t optional—it’s the security gatekeeper preventing data corruption at scale.

How Shopify’s HMAC-SHA256 verification actually works

Shopify signs every webhook request using HMAC-SHA256. Here’s the exact process:

  • Shopify takes the raw bytes of the request body
  • Applies HMAC-SHA256 using your app’s shared secret as the key
  • Base64-encodes the result
  • Sets the X-Shopify-Hmac-SHA256 header with this value
  • Sends the request to your endpoint

Your job is to replicate these steps on your side and compare your computed HMAC with the one Shopify provided. A match means the payload is authentic. A mismatch means it’s forged—reject it immediately.

Production-ready Node.js verification code

Below is a battle-tested implementation for Express.js apps. The key is handling the raw body before any parsing occurs.

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;
  }

  // Use constant-time comparison to prevent timing attacks
  return crypto.timingSafeEqual(hmacBuffer, generatedBuffer);
}

Now, integrate this into your Express route. Critical detail: use express.raw() to buffer the raw body before any parsing middleware interferes.

app.post(
  '/webhooks/shopify',
  express.raw({ type: 'application/json' }),
  (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 that verification passed
    const payload = JSON.parse(req.body);

    // Acknowledge immediately, process asynchronously
    res.status(200).send('OK');
    processWebhookAsync(payload);
  }
);

Python implementation for Django and Flask

Python developers can use the following function for verification. It works in both Django and Flask when processing raw request bodies.

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')

    # Use constant-time comparison to prevent timing attacks
    return hmac.compare_digest(computed_hmac, hmac_header)

In a Flask application, access the raw body and verify before parsing:

@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

Common mistakes that silently break verification

Even experienced developers trip up on these five pitfalls. Fix them before shipping.

1\. Parsing the body before verifying HMAC

This is the most frequent issue. If your middleware transforms the raw body into JSON before your verification code runs, the HMAC won’t match. The developer sees 401 errors everywhere, disables verification, and ships an insecure app.

Fix: Apply express.raw() or request.get_data() specifically to your webhook route. Never parse the body before HMAC verification.

2\. Using loose string comparison for HMAC values

Using === or == to compare HMAC strings leaks timing information. An attacker can measure microsecond differences to deduce the correct signature byte by byte—this is a timing side-channel attack.

Fix: Always use cryptographic constant-time comparison functions like crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python.

3\. Ignoring replay attacks

HMAC proves authenticity, but not freshness. A valid payload from yesterday remains valid today. Attackers can intercept legitimate webhooks and replay them repeatedly.

Fix: Validate the created_at timestamp in the payload. Reject anything older than five minutes. Store event IDs in a database and block duplicates.

import datetime

def is_replay_attack(payload, allowed_window_minutes=5):
    event_time = datetime.datetime.fromisoformat(payload['created_at'])
    now = datetime.datetime.utcnow()
    time_diff = (now - event_time).total_seconds() / 60
    return time_diff > allowed_window_minutes

4\. Skipping shop domain validation

All stores using your app share the same signing secret. A valid webhook from store A could be replayed against your handler for store B.

Fix: Always validate the X-Shopify-Shop-Domain header against your list of registered shops.

const shopDomain = req.headers['x-shopify-shop-domain'];
if (!registeredShops.includes(shopDomain)) {
  return res.status(403).send('Forbidden');
}

5\. Hardcoding secrets in your codebase

Storing webhook secrets in source code or committing them to Git is a security anti-pattern. Logs often have weaker access controls than your codebase.

Fix: Store secrets in environment variables or a secrets manager. Never log the secret value.

The production checklist before going live

Before deploying any Shopify webhook consumer, run through this checklist:

  • Use HTTPS only—no HTTP fallback. TLS 1.2 or higher is required.
  • Buffer the raw request body before any parsing middleware runs.
  • Verify HMAC using constant-time comparison to prevent timing attacks.
  • Validate the timestamp in each payload to block replay attacks.
  • Confirm the X-Shopify-Shop-Domain header matches a registered store.
  • Store the secret securely in environment variables or a secrets manager.
  • Log verification failures for debugging, but never log the secret.
  • Respond with 200 OK immediately after verification to avoid timing clues.
  • Process payloads asynchronously to maintain performance.

Verification isn’t a one-time setup—it’s a critical layer in your application’s security posture. A single missed step can cascade into corrupted data across your entire ecosystem. Implement these practices now to prevent costly fixes later.

AI summary

Learn the correct way to verify Shopify webhooks with Node.js and Python code. Avoid common security pitfalls and prevent forged data from corrupting your systems.

Comments

00
LEAVE A COMMENT
ID #6JNJBU

0 / 1200 CHARACTERS

Human check

7 + 8 = ?

Will appear after editor review

Moderation · Spam protection active

No approved comments yet. Be first.