../dumpster-dives

CRIT #webhooks#hmac#node updated Aug 3, 2026

The Unlocked Webhook

Your app trusts any POST that looks like Shopify. Bandit noticed. Here's how he forged an order — and the six lines that stop him.

The dive

Bandit didn’t need your password. He just needed your webhook URL — and those aren’t secret. They show up in your app’s network tab, your logs, sometimes your public repo.

Your endpoint accepts POST /webhooks/orders/create, parses the JSON, and marks an order as paid. It never checks who sent the request.

So Bandit sent his own:

curl -X POST https://your-app.com/webhooks/orders/create \
  -H "Content-Type: application/json" \
  -d '{"id": 9001, "total_price": "0.00", "financial_status": "paid"}'

Free order, fulfilled. No breach required — you invited him in.

In plain terms

Bandit the raccoon in a trash can, pressing a wax seal onto a forged 'Fake order #100' letter, with a mailbox in the background
Bandit forging the return address. Without Shopify’s seal, your mailbox believes him.

Forget the code for a second. Think of the mail.

Your webhook endpoint is a mailbox bolted to the wall, open to the street. All day, envelopes drop in — and every one has a return address that says “Shopify.”

But a return address is just ink. Bandit can scrawl “Shopify” on an envelope he forged in a trash can — an order that was never paid — and post it through your slot. Your mailbox can’t tell the difference. It opens every letter and does exactly what it says.

The fix isn’t a bigger mailbox. It’s a seal. Real Shopify letters arrive stamped with a wax seal that only Shopify can press — because only you and Shopify know the secret shape of the stamp. Before you act on any letter, you check the seal. Bandit can forge the return address all day; he can’t forge the seal, so his letters go straight in the bin.

That seal is the HMAC signature. Everything below is just how to check it.

Here’s the whole raid, start to finish:

sequenceDiagram
    autonumber
    participant B as Bandit
    participant A as Your App
    participant DB as Orders DB
    Note over B,A: Webhook URL is public — not a secret
    B->>A: POST /webhooks/orders/create (forged)
    A->>A: Parse JSON, no HMAC check
    A->>DB: Mark order #9001 PAID
    DB-->>A: ok
    A-->>B: 200 OK
    Note over B,DB: Free order, fulfilled — no breach needed

Why this happens

Shopify signs every webhook with an HMAC-SHA256 digest of the raw request body, keyed by your app’s client secret, in the X-Shopify-Hmac-Sha256 header. If you don’t verify it, “from Shopify” is just a claim.

Two gotchas that quietly break verification even when devs try:

  • You hash the parsed body, not the raw bytes. JSON.parse then JSON.stringify reorders keys and drops whitespace — the digest won’t match. You must hash the raw body exactly as received.
  • You use == to compare digests. String comparison short-circuits on the first differing byte, leaking timing info. Use a constant-time compare.

The fix

Capture the raw body and verify before you parse anything. The check is the same idea in any language — hash the raw bytes with your secret, base64-encode, and compare in constant time:

import crypto from 'node:crypto';

// Returns true only if the body was signed with your app secret.
export function verifyShopifyWebhook(rawBody, hmacHeader, secret) {
  const digest = crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')          // raw bytes, not the parsed object
    .digest('base64');

  const a = Buffer.from(digest);
  const b = Buffer.from(hmacHeader ?? '');
  return a.length === b.length && crypto.timingSafeEqual(a, b); // constant-time
}
import base64, hashlib, hmac

def verify_shopify_webhook(raw_body: bytes, hmac_header: str, secret: str) -> bool:
    digest = base64.b64encode(
        hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
    ).decode("utf-8")
    # compare_digest is constant-time and length-safe
    return hmac.compare_digest(digest, hmac_header or "")
require "base64"
require "openssl"

def verify_shopify_webhook(raw_body, hmac_header, secret)
  digest = Base64.strict_encode64(
    OpenSSL::HMAC.digest("sha256", secret, raw_body)
  )
  OpenSSL.fixed_length_secure_compare(digest, hmac_header.to_s) # constant-time
rescue ArgumentError
  false # lengths differ -> not a match
end
<?php
function verify_shopify_webhook(string $rawBody, string $hmacHeader, string $secret): bool {
    $digest = base64_encode(hash_hmac('sha256', $rawBody, $secret, true));
    return hash_equals($digest, $hmacHeader); // constant-time, length-safe
}
# Verify a captured delivery straight from the command line.
# body.raw = the exact bytes Shopify sent (do not reformat the JSON).
openssl dgst -sha256 -hmac "$SHOPIFY_API_SECRET" -binary < body.raw | openssl base64
# Compare the output to the request's X-Shopify-Hmac-Sha256 header — they must match.

Wire it in before your JSON body parser runs, and reject on failure with a 401 (the wiring differs per framework — the check above does not):

app.post('/webhooks/*', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyShopifyWebhook(
    req.body,                                  // Buffer, thanks to express.raw
    req.get('X-Shopify-Hmac-Sha256'),
    process.env.SHOPIFY_API_SECRET,
  );
  if (!ok) return res.sendStatus(401);
  const payload = JSON.parse(req.body.toString('utf8'));
  // ...safe to trust payload now
});

The gate every request now passes through — forgeries die before they touch your data:

flowchart TD
    IN([Incoming webhook POST]) --> RAW[Read raw request body bytes]
    RAW --> HMAC[Compute HMAC-SHA256 with app secret]
    HMAC --> CMP{timingSafeEqual matches<br/>X-Shopify-Hmac-Sha256?}
    CMP -- no --> REJ[Return 401, log source IP]:::bad
    CMP -- yes --> PARSE[Parse JSON body] --> OK[Process the order]:::good
    classDef bad fill:#1a0f0f,stroke:#FF5F56,color:#FF5F56;
    classDef good fill:#0d1a12,stroke:#7CF9A0,color:#7CF9A0;

Let Shopify’s tooling do it

You rarely need to write those lines by hand. Shopify’s official app templates verify the HMAC before your handler runs. In the Remix / React Router template, one call does it:

// app/routes/webhooks.jsx
import { authenticate } from "../shopify.server";

export const action = async ({ request }) => {
  const { topic, shop, payload } = await authenticate.webhook(request);
  // you only reach this line if the signature was valid
  return new Response();
};

If authenticate.webhook() can’t verify the signature, it rejects the request for you — your handler never sees a forged payload. Reach for the manual code above only when you’re outside the template (a bare Express route, a serverless function, another language).

Three things the docs will save you from learning the hard way:

  • HMAC verification applies to HTTPS deliveries only. Google Cloud Pub/Sub and Amazon EventBridge deliveries are authenticated by the platform instead.
  • Every delivery carries a delivery ID (X-Shopify-Webhook-Id). Store it and drop duplicates — Shopify can deliver the same event more than once.
  • If you rotate your client secret, it can take up to an hour for new digests to be signed with it. Accept both secrets during the overlap.

Alternatives to webhooks

Plain HTTPS webhooks put a public endpoint on the internet and hand you the whole job: verify every payload, absorb every burst, retry your own failures. Sometimes the safer move is to change the transport — so the “is this really Shopify?” problem shrinks, or becomes someone else’s entirely.

flowchart TD
    SH([Shopify event]) --> Q{delivery method}
    Q --> W[HTTPS webhook]:::self
    Q --> EV[Next Gen Events]:::self
    Q --> EB[Amazon EventBridge]:::mgd
    Q --> PS[Google Pub/Sub]:::mgd
    Q --> HD["Hookdeck gateway (3rd-party)"]:::mgd
    W --> V1[You verify the HMAC]:::self
    EV --> V1
    EB --> V2[Platform authenticates delivery]:::mgd
    PS --> V2
    HD --> V2
    classDef self stroke:#FFB454,color:#FFB454,fill:#1a140a;
    classDef mgd stroke:#7CF9A0,color:#7CF9A0,fill:#0d1a12;

Shopify Next Gen Events — Shopify’s next-generation subscription mechanism, in developer preview and set to become the primary way to subscribe as topic coverage grows. HTTPS delivery still carries an HMAC (Shopify-Hmac-Sha256) you verify the same way, but it’s built for broader, more reliable event coverage. If you’re building new, watch this space. → About Events, Events vs. webhooks

Amazon EventBridge — Shopify publishes events straight into an EventBridge bus in your AWS account. There’s no public endpoint to expose and no HMAC to check — AWS authenticates the delivery for you — plus native retries, filtering, and fan-out to Lambda/SQS. You subscribe with an ARN instead of a URL. → EventBridge endpoint, Manage subscriptions

Google Cloud Pub/Sub — the GCP equivalent: Shopify publishes to a topic you own (pubsub://project:topic) and your Cloud Functions or services pull at their own pace. Again, no HMAC verification — the platform authenticates it — with durable, replayable, back-pressure-friendly delivery. → Pub/Sub endpoint

Hookdeck 3rd-party — a third-party event gateway that sits in front of your HTTPS endpoint and handles verification, retries, queueing, rate-limiting, and replay/observability for you. Reach for it when you want managed reliability without moving onto AWS or GCP infrastructure. → hookdeck.com

🦝 The catch: managed buses don’t delete the lock, they move it. EventBridge and Pub/Sub drop HMAC because the cloud platform’s IAM now authenticates delivery — so your job becomes locking down the bus/topic permissions instead. Only Shopify should be able to put events in; only you should be able to read them. Different lock, same principle.

Trap card

If you can console.log(req.body) as an object before you verify the HMAC, your body parser already ran — and it already destroyed the bytes you needed. Verify first.

Lock the can

  • Verify X-Shopify-Hmac-Sha256 on every webhook route
  • Prefer the official template’s authenticate.webhook() over hand-rolled checks
  • Hash the raw request body, never the re-serialized object
  • Compare with crypto.timingSafeEqual, never ==
  • Dedupe on the X-Shopify-Webhook-Id delivery ID
  • Return 401 and log the source IP on mismatch
  • Store SHOPIFY_API_SECRET in env, not in code

Straight from Shopify