Implementing Shopify webhooks correctly isn’t just about receiving data—it’s about verifying authenticity. A single verification mistake can expose your systems to data corruption or denial-of-service attacks. Here’s how to get it right.
Why unverified webhooks become attack vectors
Shopify webhook URLs are public endpoints. Without verification, they become open invitations for malicious actors to inject fake data or overload your systems. Attackers can forge order events, manipulate customer records, or trigger inventory changes that ripple through connected ERP, CRM, or warehouse systems.
Consider this: if your webhook feeds a downstream system handling order fulfillment, a single forged payload could cause shipments to the wrong addresses or duplicate orders that strain inventory. Verification isn’t optional—it’s the security gatekeeper preventing data corruption at scale.
How Shopify’s HMAC-SHA256 verification actually works
Shopify signs every webhook request using HMAC-SHA256. Here’s the exact process:
- Shopify takes the raw bytes of the request body
- Applies HMAC-SHA256 using your app’s shared secret as the key
- Base64-encodes the result
- Sets the
X-Shopify-Hmac-SHA256header with this value - Sends the request to your endpoint
Your job is to replicate these steps on your side and compare your computed HMAC with the one Shopify provided. A match means the payload is authentic. A mismatch means it’s forged—reject it immediately.
Production-ready Node.js verification code
Below is a battle-tested implementation for Express.js apps. The key is handling the raw body before any parsing occurs.
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;
}
// Use constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(hmacBuffer, generatedBuffer);
}Now, integrate this into your Express route. Critical detail: use express.raw() to buffer the raw body before any parsing middleware interferes.
app.post(
'/webhooks/shopify',
express.raw({ type: 'application/json' }),
(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 that verification passed
const payload = JSON.parse(req.body);
// Acknowledge immediately, process asynchronously
res.status(200).send('OK');
processWebhookAsync(payload);
}
);Python implementation for Django and Flask
Python developers can use the following function for verification. It works in both Django and Flask when processing raw request bodies.
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')
# Use constant-time comparison to prevent timing attacks
return hmac.compare_digest(computed_hmac, hmac_header)In a Flask application, access the raw body and verify before parsing:
@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', 200Common mistakes that silently break verification
Even experienced developers trip up on these five pitfalls. Fix them before shipping.
1\. Parsing the body before verifying HMAC
This is the most frequent issue. If your middleware transforms the raw body into JSON before your verification code runs, the HMAC won’t match. The developer sees 401 errors everywhere, disables verification, and ships an insecure app.
Fix: Apply express.raw() or request.get_data() specifically to your webhook route. Never parse the body before HMAC verification.
2\. Using loose string comparison for HMAC values
Using === or == to compare HMAC strings leaks timing information. An attacker can measure microsecond differences to deduce the correct signature byte by byte—this is a timing side-channel attack.
Fix: Always use cryptographic constant-time comparison functions like crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python.
3\. Ignoring replay attacks
HMAC proves authenticity, but not freshness. A valid payload from yesterday remains valid today. Attackers can intercept legitimate webhooks and replay them repeatedly.
Fix: Validate the created_at timestamp in the payload. Reject anything older than five minutes. Store event IDs in a database and block duplicates.
import datetime
def is_replay_attack(payload, allowed_window_minutes=5):
event_time = datetime.datetime.fromisoformat(payload['created_at'])
now = datetime.datetime.utcnow()
time_diff = (now - event_time).total_seconds() / 60
return time_diff > allowed_window_minutes4\. Skipping shop domain validation
All stores using your app share the same signing secret. A valid webhook from store A could be replayed against your handler for store B.
Fix: Always validate the X-Shopify-Shop-Domain header against your list of registered shops.
const shopDomain = req.headers['x-shopify-shop-domain'];
if (!registeredShops.includes(shopDomain)) {
return res.status(403).send('Forbidden');
}5\. Hardcoding secrets in your codebase
Storing webhook secrets in source code or committing them to Git is a security anti-pattern. Logs often have weaker access controls than your codebase.
Fix: Store secrets in environment variables or a secrets manager. Never log the secret value.
The production checklist before going live
Before deploying any Shopify webhook consumer, run through this checklist:
- Use HTTPS only—no HTTP fallback. TLS 1.2 or higher is required.
- Buffer the raw request body before any parsing middleware runs.
- Verify HMAC using constant-time comparison to prevent timing attacks.
- Validate the timestamp in each payload to block replay attacks.
- Confirm the
X-Shopify-Shop-Domainheader matches a registered store. - Store the secret securely in environment variables or a secrets manager.
- Log verification failures for debugging, but never log the secret.
- Respond with 200 OK immediately after verification to avoid timing clues.
- Process payloads asynchronously to maintain performance.
Verification isn’t a one-time setup—it’s a critical layer in your application’s security posture. A single missed step can cascade into corrupted data across your entire ecosystem. Implement these practices now to prevent costly fixes later.
AI summary
Learn the correct way to verify Shopify webhooks with Node.js and Python code. Avoid common security pitfalls and prevent forged data from corrupting your systems.