Originally published on KolachiTech
I have reviewed a lot of Shopify app codebases. One thing shows up constantly: webhook verification that looks correct but silently fails. The developer gets frustrated, removes the check, ships without it, and ships a vulnerability instead.
This post covers how Shopify webhook verification actually works, the implementation mistakes that trip people up, and what a production-ready setup looks like. I will share working Node.js and Python code you can drop in today.
Why Your Webhook Endpoint Is a Target
Your Shopify webhook URL is just an HTTP endpoint. It does not require authentication to reach. Anyone who knows it can POST to it.
Without verification, an attacker can:
- Fake order fulfillment events
- Inject customer records
- Trigger inventory changes
- Flood your endpoint until it falls over
This matters more than you think. If your webhooks feed an ERP, a CRM, or a fulfillment warehouse, one forged payload corrupts data in every downstream system simultaneously.
Verification is not optional. It is the lock on the front door.
What Shopify Actually Does
Shopify uses HMAC-SHA256 to sign every webhook request it sends.
Here is the exact flow:
- Shopify takes the raw bytes of your request body
- It runs HMAC-SHA256 using your app's shared secret as the key
- It Base64-encodes the result
- It drops that value into the
X-Shopify-Hmac-SHA256request header - It sends the request to your endpoint
Your job is to repeat steps 1 through 3 on your side and compare what you compute with what Shopify sent.
If they match: authentic. If they differ: reject immediately, do not process.
The Node.js Implementation
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;
}
// Constant-time comparison — never use ===
return crypto.timingSafeEqual(hmacBuffer, generatedBuffer);
}
Express.js setup that actually works:
app.post(
'/webhooks/shopify',
express.raw({ type: 'application/json' }), // Buffer raw body BEFORE any parsing
(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
const payload = JSON.parse(req.body);
// Acknowledge immediately, process async
res.status(200).send('OK');
processWebhookAsync(payload);
}
);
The Python Implementation
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')
# Constant-time comparison
return hmac.compare_digest(computed_hmac, hmac_header)
Django/Flask usage:
@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
The Mistakes That Break Verification (And Ship Vulnerabilities)
Mistake 1: Parsing before verifying
This is the most common one. Your body parsing middleware runs before your verification code and transforms the raw bytes. Your computed HMAC no longer matches what Shopify signed.
The developer sees 401 everywhere. They remove verification. The app ships insecure.
Fix: Use express.raw() on your webhook route specifically, not express.json().
Mistake 2: Using === for comparison
// WRONG — leaks timing information
if (generatedHmac === hmacHeader) { ... }
// RIGHT — constant-time
crypto.timingSafeEqual(hmacBuffer, generatedBuffer);
An attacker can measure microsecond differences in how long === takes to compare strings and deduce the correct signature byte by byte. This is a real attack class called a timing side-channel.
Mistake 3: No replay protection
HMAC proves the payload is authentic. It does not prove it is fresh.
A payload from yesterday is still cryptographically valid today. An attacker who intercepts a legitimate webhook can replay it as many times as they want.
Fix: Validate the timestamp in the payload. Reject anything older than 5 minutes. Store event IDs and reject duplicates.
function isReplayAttack(payload) {
const eventTime = new Date(payload.created_at).getTime();
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
return (now - eventTime) > fiveMinutes;
}
Mistake 4: No shop domain validation
All stores using your app share the same signing secret. A valid webhook from store A is also cryptographically valid when replayed against your store B handler.
Fix: Always validate X-Shopify-Shop-Domain against your database of registered shops.
const shopDomain = req.headers['x-shopify-shop-domain'];
if (!registeredShops.includes(shopDomain)) {
return res.status(403).send('Forbidden');
}
Mistake 5: Storing the secret in code
# Never do this
SHOPIFY_WEBHOOK_SECRET = "shpss_abc123" # Committed to git
# Do this
process.env.SHOPIFY_WEBHOOK_SECRET # Environment variable or secrets manager
Logs are often less locked down than your codebase. Never log the secret either.
The Production Checklist
Before shipping any webhook consumer:
Transport:
- HTTPS only, no HTTP fallback
- TLS 1.2 or higher
Verification:
- Raw body buffered before any parsing middleware
- HMAC computed with correct shared secret
- Constant-time comparison used
- Failed verification returns 401/403 immediately
Replay protection:
- Timestamp validated, reject payloads older than 5 minutes
- Event IDs deduplicated (Redis or DB)
- Shop domain validated against allowed list
Secrets:
- Secret in environment variable or secrets manager
- Secret rotation procedure documented and tested
- Verification failures logged (not the secret, the failure)
Reliability:
- Handler returns 200 immediately, processes async
- Dead letter queue configured for processing failures
Why Acknowledging Immediately Matters
Shopify expects a 2xx response within 5 seconds. If you do slow processing synchronously, you will hit timeouts, and Shopify will retry the webhook. Retries without idempotency controls mean double-processing.
The pattern is: verify, acknowledge with 200, hand off to a queue or async worker, process there.
res.status(200).send('OK'); // Shopify gets its answer
processWebhookAsync(payload); // You do the actual work
What to Read Next
If you are building a serious Shopify integration, verification is just the first layer. These are worth reading after this:
- Queue-based webhook processing for async pipeline design
- Idempotency strategies for duplicate event handling
- Dead letter queues for Shopify webhooks for failure recovery
- Building reliable webhook consumers for end-to-end patterns
Webhook verification is a two-hour implementation that prevents a category of attacks permanently. Do it right once. The checklist above covers everything you need to ship it confidently.
Top comments (0)