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

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.parsethenJSON.stringifyreorders 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-Sha256on 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-Iddelivery ID - Return
401and log the source IP on mismatch - Store
SHOPIFY_API_SECRETin env, not in code
Straight from Shopify
- Verify webhook deliveries — the canonical guide: the managed
authenticate.webhook()helper, manual verification, and HTTPS delivery considerations. - About client credentials — where the signing secret comes from, and how to rotate or revoke it.
- Manage webhook subscriptions — HTTPS, Amazon EventBridge, and Google Pub/Sub delivery methods.
- About Events and webhooks — how Next Gen Events compares to classic webhooks.