# 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.

**Severity:** crit · **Updated:** 2026-08-04 · **Tags:** webhooks, hmac, node

_Source: https://verybadpanda.dev/dumpster-dives/verify-webhook-hmac_

## The story

It's 6:52 on a Friday when the first refund request lands. Then four more, stacked in the inbox like they've been waiting.

Your app auto-fulfills orders the second Shopify says they're paid. That's the whole pitch. That's why merchants pay you. So when a store owner emails asking why you shipped $3,000 of product against orders that never actually cleared, your first thought is *glitch*. You pull the logs. The orders are right there. `financial_status: paid`. Your app did exactly what it was told.

The problem is who told it.

You open the endpoint that receives "order paid" events — really read it, for the first time in months. It parses the JSON. It trusts the JSON. Nowhere does it ask the one question that matters: *did this actually come from Shopify?* The URL was never a secret. It sits in plain view in your app's network tab. Anyone who looked could send it anything they wanted.

Someone looked.

By Monday the merchant has churned. The review they leave uses the word "insecure," and it's the first result when your next prospect searches your app. You're staring at a cursor, writing the apology email you least wanted to send — and doing the math on how many other stores are wide open right now.

---

## 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:

```bash
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](https://verybadpanda.dev/assets/bandit-forged-letter.png)

*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:

```mermaid
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](https://shopify.dev/docs/apps/build/webhooks/verify-deliveries) 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:

```js

// 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
}
```

```python

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 "")
```

```ruby
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
<?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
}
```

```bash
# 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):

```js
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:

```mermaid
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](https://shopify.dev/docs/apps/build/webhooks/verify-deliveries) verify the HMAC *before your handler runs*. In the Remix / React Router template, one call does it:

```js
// app/routes/webhooks.jsx

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](https://shopify.dev/docs/apps/build/webhooks/verify-deliveries) 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](https://shopify.dev/docs/apps/build/authentication-authorization/client-secrets), 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.

```mermaid
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](https://shopify.dev/docs/apps/build/events), [Events vs. webhooks](https://shopify.dev/docs/apps/build/events-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](https://shopify.dev/docs/api/admin-graphql/latest/objects/WebhookEventBridgeEndpoint), [Manage subscriptions](https://shopify.dev/docs/apps/build/webhooks/subscribe)

**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](https://shopify.dev/docs/api/admin-graphql/latest/objects/WebhookPubSubEndpoint)

**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](https://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

- [Verify webhook deliveries](https://shopify.dev/docs/apps/build/webhooks/verify-deliveries) — the canonical guide: the managed `authenticate.webhook()` helper, manual verification, and HTTPS delivery considerations.
- [About client credentials](https://shopify.dev/docs/apps/build/authentication-authorization/client-secrets) — where the signing secret comes from, and how to rotate or revoke it.
- [Manage webhook subscriptions](https://shopify.dev/docs/apps/build/webhooks/subscribe) — HTTPS, Amazon EventBridge, and Google Pub/Sub delivery methods.
- [About Events and webhooks](https://shopify.dev/docs/apps/build/events-webhooks) — how Next Gen Events compares to classic webhooks.
