Anyone on the internet can send a POST request to your webhook URL. The only thing that separates a real Shopify delivery from a forged one is the signature in the X-Shopify-Hmac-Sha256 header. If that check is missing, broken or disabled, the caller decides what your app believes happened in a store.
The consequences depend on the topic. A forged shop/redact can erase a store’s data, a forged app/uninstalled can disable the app for a live merchant, and a forged order or billing event can trigger fulfilment or unlock a paid plan.
Getting a valid delivery through your handler proves very little, because every mistake below still passes that test. This article covers how the signature works, eight ways verification goes wrong, a tested implementation and a set of curl checks.
The short version
- Compute HMAC-SHA256 over the raw request bytes with your app’s client secret, then compare it with the Base64-decoded header in constant time.
- Verify first. Do nothing with the body, the headers or your queue until the signature passes.
- An invalid signature on a compliance webhook must return
401 Unauthorized. A500or400does not meet the requirement. - The signature covers the body only. Headers are not signed, and there is no signed timestamp.
- During client secret rotation, accept both secrets.
- Make handlers idempotent. Signature checks do not stop a captured delivery from being sent again.
How the signature works
Shopify’s verification guide describes the scheme: each HTTPS delivery carries a Base64-encoded HMAC-SHA256 digest, generated with the app’s client secret and the raw request body. Your app repeats the calculation and compares the results.
This applies to HTTPS deliveries. Google Cloud Pub/Sub and Amazon EventBridge deliveries do not use it, so moving to one of them removes this class of problem for the topics you move.
Knowing exactly what is signed matters as much as the calculation. Shopify’s delivery structure reference lists the metadata that arrives alongside the body:
| Header | Purpose | Covered by the signature? |
|---|---|---|
X-Shopify-Hmac-Sha256 | The signature itself | Not applicable |
X-Shopify-Topic | Topic name, such as orders/create | No |
X-Shopify-Shop-Domain | Store that triggered the event | No |
X-Shopify-Webhook-Id | Unique ID per delivery | No |
X-Shopify-Event-Id | Shared across deliveries from one merchant action | No |
X-Shopify-Triggered-At | When Shopify triggered the delivery | No |
Shopify defines the digest in terms of the client secret and the body alone, so the last column follows from the scheme. Mistakes 6 and 7 are about what that means in practice.
Eight mistakes that break verification
1. Verifying a parsed and re-serialized body
This is the classic failure. A global JSON parser runs first, so the handler rebuilds the body and signs that:
// Broken: this is not the byte sequence Shopify signed
app.use(express.json());
app.post('/webhooks', (req, res) => {
const digest = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(req.body))
.digest('base64');
// ...
});
A JSON round trip does not preserve bytes. "https:\/\/a.example" becomes "https://a.example", "café" becomes "café" and 1.50 becomes 1.5. Verification may pass in a test with a simple payload, then fail on real data containing an accented product title. That intermittent failure is how teams end up commenting out the check to stop the retries.
Read the raw bytes before anything parses them: express.raw() in Express, php://input or $request->getContent() in PHP and Laravel, request.get_data() in Flask, request.raw_post in Rails.
2. Comparing the wrong encoding, or comparing with ==
The header is Base64. A digest calculated as hex never matches it. The reverse failure is worse: if someone “fixes” the mismatch by loosening the comparison, the check stops checking anything.
Compare in constant time with crypto.timingSafeEqual, hash_equals, hmac.compare_digest or ActiveSupport::SecurityUtils.secure_compare. A timing attack against a remote HMAC comparison is hard to carry out, but the fix is one function call, and an ordinary == here will be flagged in any serious code review.
3. Crashing on malformed input
The check should return a clean rejection for every bad input, not an exception. This one is easy to miss because the mistake is in code that looks right. Shopify’s own Node example, which we ran as published on Node 22 and Express 4, has correct cryptography:
const hmacValid = crypto.timingSafeEqual(
Buffer.from(calculatedHmacDigest, 'base64'),
Buffer.from(shopifyHmac, 'base64'),
);
But timingSafeEqual throws when the buffers differ in length, and Buffer.from(undefined) throws too. Here is how that example responded:
| Request | Expected | Actual |
|---|---|---|
| Valid signature | 200 | 200 |
| Tampered body, correct-length signature | 401 | 401 |
| Header missing | 401 | 500 |
| Signature of the wrong length | 401 | 500 |
| Hex digest in the header | 401 | 500 |
| Empty request body | 401 | 500 |
The forged request is still rejected, so this is not a bypass. It is a compliance failure instead, and also noise: anyone can fill your error tracker with stack traces. Check the header type and the buffer lengths before you call timingSafeEqual.
4. Returning the wrong status code
Shopify’s privacy law compliance page is specific: if a mandatory compliance webhook arrives with an invalid HMAC header, the app must return 401 Unauthorized. The same page says an app that does not respond to these webhooks as required will be rejected, so a stray 500 from mistake 3 matters during review.
Do not rely on a generic example here either. The library snippet in Shopify’s verification guide returns 400 for an invalid request, which is fine as an illustration but is not what the compliance requirement asks for. Return 401 on every webhook route so there is one behavior to test.
The opposite failure is returning 200 to everything to “keep Shopify happy”, with verification only logging a warning. Forged requests are then processed as usual.
5. Coverage gaps and fail-open logic
Verification often exists but does not protect everything. Look for:
- Routes outside the middleware. Compliance topics are frequently added late, in a separate handler, and never get wrapped.
- Work done before the check. Pushing the body to a queue first and verifying in the worker lets anyone fill your queue. The verified result must also travel with the job.
- Environment switches. Shopify’s guidance is to skip verification only in development with mock tools. A flag such as
SKIP_HMAC=trueeventually reaches staging, and sometimes production. - A missing secret. With
process.env.SECRET || ''a misconfigured deploy signs with an empty key, which anyone can reproduce. Refuse to start without the secret. - The wrong secret for the app. One backend serving staging and production apps, or several apps, must select the secret deliberately, and must not fall back to trying a secret that belongs to a different environment.
6. Treating headers as verified
A passing signature proves the body came from Shopify. It says nothing about the headers next to it.
Suppose a signed delivery leaks through request logging, a debugging proxy or a third-party log service. Someone who holds it can send the same body and signature with a different X-Shopify-Shop-Domain, and the signature still validates. If the handler deletes data for the store named in the header, a shop/redact delivery for the attacker’s own store becomes a shop/redact for the victim’s. The topic header can be swapped in the same way.
The precondition is real: the attacker needs a signed delivery first, so this is less serious than a missing check. But it is cheap to close, and compliance payloads carry the store identity inside the signed body:
// shop_domain in the body is signed; the header is not
const payload = JSON.parse(req.body.toString('utf8'));
const headerShop = req.get('X-Shopify-Shop-Domain');
if (payload.shop_domain && payload.shop_domain !== headerShop) {
return res.sendStatus(401);
}
Many topics have no shop field in the payload, so this check will not cover everything. Check the payload for each topic you handle in the webhooks reference. For high-impact actions, confirm the state through the Admin API before acting on it, and keep signature headers and raw webhook bodies out of your logs. Our cross-store IDOR article covers the wider problem of tying every action to the right store.
7. Assuming a valid signature means a fresh request
There is no signed timestamp, so a signature does not expire. X-Shopify-Triggered-At is useful for ordering events, but it is unsigned, so it cannot prove that a delivery is new. The same goes for X-Shopify-Webhook-Id: it works well for Shopify’s own retries, and someone replaying a captured delivery can change it.
Duplicates are also normal without any attacker. Shopify says the same webhook may arrive more than once, and recommends idempotent processing, with X-Shopify-Webhook-Id as the deduplication key if your processing is not idempotent. It also does not guarantee ordering.
Build for both cases. Make state changes idempotent: “mark order X as paid” is safe to repeat, “add 100 loyalty points” is not, unless you key it on the order. Then record delivery IDs atomically, because a separate read and write lets two simultaneous deliveries through:
-- webhook_id has a unique index, so only one delivery can claim it
INSERT INTO webhook_deliveries (webhook_id, shop, received_at)
VALUES ($1, $2, now())
ON CONFLICT (webhook_id) DO NOTHING;
-- 0 rows inserted: duplicate, return 200 and stop
Write this row and enqueue the job in one transaction. Otherwise a crash between the two steps marks the delivery as handled when nothing has processed it.
Be careful with the alternative key: X-Shopify-Event-Id is shared between deliveries from one merchant action, so with two subscriptions on a topic, deduplicating on it drops a legitimate delivery.
8. Breaking rotation, and then disabling the check
Client secrets need rotating when one is exposed or when someone with access leaves. Shopify’s rotation guide explains the part that catches teams out: webhooks are signed with the app’s oldest unrevoked secret. After you generate a new one, deliveries keep arriving signed with the old one until you revoke it.
If the deployment switches to the new secret immediately, every genuine webhook fails with 401. Shopify then retries up to 8 times over 4 hours, and a subscription configured through the Admin API is deleted after 8 consecutive failures. Under that pressure, someone is likely to turn verification off “for now”.
Accept both secrets during the transition, and remove the old one only after you revoke it. The implementation below supports that. If the secret is being actively misused, the priority changes: Shopify says to revoke it immediately and accept the downtime.
A verified implementation
This Express version handles every case in the test section below. The route is registered before express.json(), so it receives raw bytes:
import crypto from 'node:crypto';
import express from 'express';
const secrets = [
process.env.SHOPIFY_CLIENT_SECRET,
process.env.SHOPIFY_CLIENT_SECRET_PREVIOUS, // set only during rotation
].filter(Boolean);
if (!secrets.length) throw new Error('SHOPIFY_CLIENT_SECRET is not set');
const isValid = (rawBody, header) => {
if (!Buffer.isBuffer(rawBody) || typeof header !== 'string') return false;
const received = Buffer.from(header, 'base64');
return secrets.some((secret) => {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
};
const app = express();
app.post('/webhooks', express.raw({ type: '*/*' }), (req, res) => {
if (!isValid(req.body, req.get('X-Shopify-Hmac-Sha256'))) return res.sendStatus(401);
// Verified: check the shop, record the delivery, enqueue the job
res.sendStatus(200);
});
app.use(express.json());
The same logic in plain PHP:
$secrets = array_filter([
getenv('SHOPIFY_CLIENT_SECRET'),
getenv('SHOPIFY_CLIENT_SECRET_PREVIOUS'), // set only during rotation
]);
$body = file_get_contents('php://input');
$received = base64_decode($_SERVER['HTTP_X_SHOPIFY_HMAC_SHA256'] ?? '', true);
$valid = false;
foreach ($secrets as $secret) {
if ($received !== false && hash_equals(hash_hmac('sha256', $body, $secret, true), $received)) {
$valid = true;
}
}
if (!$valid) {
http_response_code(401);
exit;
}
If you use Shopify’s React Router template, authenticate.webhook(request) handles verification before your handler runs. Prefer the maintained library where you can. The mistakes that remain are then mostly about coverage, headers and replays, so mistakes 5 to 8 still apply.
After verification, respond quickly. Shopify allows one second to connect and five seconds for the whole request, and treats any response outside the 200 range as a failure, including redirects. Keep the handler to verifying, recording and queueing.
How to test your endpoint
Run these against a development deployment with a test secret and a dev store’s domain, never against production. Save the script, set the two values at the top and run it:
SECRET='your-dev-client-secret'
URL='your-dev-webhook-url'
BODY='{"shop_id":954889,"shop_domain":"store-b.myshopify.com"}'
sign() { printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$1" -binary | base64; }
SIG=$(sign "$SECRET")
HEX=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
post() { curl -s -o /dev/null -w "$1: %{http_code}\n" -X POST "$URL" \
-H 'Content-Type: application/json' -H 'X-Shopify-Topic: shop/redact' \
-H 'X-Shopify-Shop-Domain: store-b.myshopify.com' "${@:2}"; }
post 'valid (want 200)' -H "X-Shopify-Hmac-Sha256: $SIG" -d "$BODY"
post 'no header (want 401)' -d "$BODY"
post 'bad length (want 401)' -H 'X-Shopify-Hmac-Sha256: abc' -d "$BODY"
post 'hex digest (want 401)' -H "X-Shopify-Hmac-Sha256: $HEX" -d "$BODY"
post 'empty key (want 401)' -H "X-Shopify-Hmac-Sha256: $(sign '')" -d "$BODY"
post 'tampered (want 401)' -H "X-Shopify-Hmac-Sha256: $SIG" -d "${BODY/954889/1}"
post 'empty body (want 401)' -H "X-Shopify-Hmac-Sha256: $SIG"
The valid request runs your real handler, so point it at a dev database. Then repeat these checks by hand:
- Every route. If each topic has its own URL, run the script against each one, compliance webhooks first.
- Non-ASCII bodies. Sign a body containing escaped Unicode and
\/, and confirm it returns200. This catches mistake 1. - A swapped shop. Sign a body for store A and send it with store B’s domain in the header. Nothing in store B may change.
- A replay. Send the same signed request twice with the same webhook ID, then with a new one. Side effects must happen once, or be harmless the second time.
- A missing secret. Start the app without the variable. It must refuse to start.
The shopify app webhook trigger CLI command is useful for testing your handler’s logic, but it does not test your subscriptions.
Quick checklist
- HMAC is calculated on raw bytes, before any body parser.
- The comparison is constant time and works on decoded bytes.
- A missing or malformed header returns
401, not500. - All webhook routes, including compliance topics, use the same verification.
- Nothing is queued or processed before verification passes.
- The app refuses to start without a secret, and no flag can skip the check outside development.
- Headers are treated as unverified, and
shop_domainin the body is compared with the header where present. - Handlers are idempotent, and delivery IDs are recorded atomically.
- Rotation accepts both secrets until the old one is revoked.
- Signatures and raw webhook bodies are not written to logs.
- A reconciliation job covers missed deliveries, as Shopify recommends.
When to get an independent test
These bugs tend to survive in a working app because nothing visibly fails. If you are preparing for App Store review, or your webhook handling has changed, our Shopify App Security Readiness review tests how your app behaves when requests are forged, not only when they are genuine. If you have already fixed a finding and need evidence for it, see our Shopify app security retest.