Shopify webhooks and CORS get mixed up constantly.
I’ve seen teams burn hours “fixing CORS” on webhook endpoints that were never touched by a browser in the first place. Shopify sends webhooks server-to-server. Browsers enforce CORS. Those are different worlds.
So the short version is:
- Shopify webhook delivery does not require CORS
- Your frontend talking to your backend may require CORS
- Your webhook endpoint should usually not be exposed for browser cross-origin access at all
That distinction saves a lot of confusion.
The mental model: webhooks are not browser requests
CORS exists because browsers restrict cross-origin JavaScript requests. If a script on https://app.example.com tries to call https://api.example.com, the browser checks CORS response headers before exposing the response to JavaScript.
Shopify webhooks don’t work like that.
Shopify’s servers send an HTTP request directly to your webhook URL:
POST /webhooks/orders/create HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-Shopify-Topic: orders/create
X-Shopify-Shop-Domain: example-shop.myshopify.com
X-Shopify-Hmac-Sha256: base64-signature
No browser is involved. No JavaScript origin policy is involved. No preflight happens. No Origin header is needed for delivery.
If your webhook fails, the problem is probably one of these:
- HMAC verification is wrong
- raw request body got modified before verification
- endpoint returns non-2xx
- TLS or routing is broken
- app auth/config is wrong
Not CORS.
When CORS does matter in a Shopify app
CORS matters when your browser-based app frontend calls your backend.
Common examples:
- an embedded app frontend on one origin calling an API on another origin
- a local dev frontend like
http://localhost:3000callinghttps://my-ngrok-app.example.com - an admin UI making AJAX requests to your app server
That’s a normal browser CORS scenario.
So you usually end up with two different routes:
- Webhook routes: server-to-server, no CORS needed
- App/API routes used by the browser: CORS may be needed
Keep them separate. Don’t slap permissive CORS on everything.
A good Express setup
Here’s a clean Node/Express setup that treats webhook routes differently from browser API routes.
Install dependencies
npm install express cors
Server with webhook verification and selective CORS
import express from "express";
import cors from "cors";
import crypto from "crypto";
const app = express();
const SHOPIFY_WEBHOOK_SECRET = process.env.SHOPIFY_WEBHOOK_SECRET;
// 1) Webhook route: use raw body for HMAC verification
app.post(
"/webhooks/orders/create",
express.raw({ type: "application/json" }),
(req, res) => {
const hmacHeader = req.get("X-Shopify-Hmac-Sha256");
const digest = crypto
.createHmac("sha256", SHOPIFY_WEBHOOK_SECRET)
.update(req.body)
.digest("base64");
const valid =
hmacHeader &&
crypto.timingSafeEqual(
Buffer.from(digest, "utf8"),
Buffer.from(hmacHeader, "utf8")
);
if (!valid) {
return res.status(401).send("Invalid webhook signature");
}
const payload = JSON.parse(req.body.toString("utf8"));
console.log("Received Shopify webhook:", payload);
// Process asynchronously in real apps
res.status(200).send("OK");
}
);
// 2) JSON parser for normal API routes
app.use(express.json());
// 3) CORS only for frontend-facing API routes
app.use(
"/api",
cors({
origin: [
"https://app.example.com",
"https://admin.example.com",
"http://localhost:3000",
],
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
})
);
app.get("/api/shop", (req, res) => {
res.json({ shop: "example-shop.myshopify.com" });
});
app.listen(8080, () => {
console.log("Server listening on port 8080");
});
That structure avoids a very common bug: parsing the webhook body as JSON before HMAC verification. Once middleware changes the body, your signature check can fail.
Why you should avoid CORS on webhook endpoints
You can technically return CORS headers on a webhook route. That doesn’t help Shopify, and it may create unnecessary exposure.
This is a bad pattern:
app.use(cors({ origin: "*" }));
Applied globally, it means your webhook endpoint now responds like a browser-accessible resource. That’s not automatically a vulnerability, but it’s sloppy. Webhook endpoints should be narrowly designed for trusted server-to-server traffic.
My rule is simple:
- Webhook path: no CORS middleware
- Frontend API path: explicit CORS policy
That gives you less attack surface and less confusion during debugging.
What a browser CORS flow looks like
Say your frontend at https://app.example.com calls:
fetch("https://api.example.com/api/shop", {
method: "GET",
credentials: "include",
});
Your backend might respond with headers like:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
If the request uses custom headers or non-simple methods, the browser may first send a preflight OPTIONS request.
That preflight never happens for Shopify webhooks.
Real-world header example
If you want a concrete reference for how public APIs expose CORS, GitHub is a good one. api.github.com returns:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning
That’s a browser-facing API decision. Access-Control-Expose-Headers tells the browser which non-simple response headers JavaScript is allowed to read.
For Shopify webhooks, none of this is relevant unless a browser is calling your endpoint. A webhook consumer usually doesn’t need Access-Control-Allow-Origin, Access-Control-Expose-Headers, or preflight handling.
Testing whether you actually have a CORS problem
A lot of “CORS issues” are just generic network or backend failures hidden behind the browser’s vague error messages.
I usually test in this order:
1. Hit the webhook endpoint with curl
curl -i -X POST https://api.example.com/webhooks/orders/create \
-H "Content-Type: application/json" \
-d '{"test":true}'
If your server responds, routing works. If HMAC fails, that’s expected unless you generated a valid signature.
2. Test your browser API route with an Origin header
curl -i https://api.example.com/api/shop \
-H "Origin: https://app.example.com"
You should see the expected CORS headers in the response.
3. Inspect headers with a dedicated tool
If you want a quick way to inspect CORS behavior and response headers without juggling browser devtools, HeaderTest is handy.
Handling preflight correctly for Shopify app APIs
If your frontend sends JSON with credentials or custom auth headers, preflight is normal.
Here’s a more explicit Express config:
const corsOptions = {
origin(origin, callback) {
const allowed = [
"https://app.example.com",
"https://admin.example.com",
"http://localhost:3000",
];
// Allow non-browser requests with no Origin header
if (!origin) return callback(null, true);
if (allowed.includes(origin)) {
return callback(null, true);
}
return callback(new Error("Not allowed by CORS"));
},
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
credentials: true,
optionsSuccessStatus: 204,
};
app.use("/api", cors(corsOptions));
app.options("/api/*", cors(corsOptions));
A couple of opinions here:
- If you use
credentials: true, don’t useorigin: "*"because browsers reject that combination. - Reflecting arbitrary origins is lazy and risky.
- Keep your allowlist tight, especially in production.
Common mistakes
1. Using JSON parsing before webhook verification
Bad:
app.use(express.json());
app.post("/webhooks/orders/create", verifyWebhook);
Better:
app.post(
"/webhooks/orders/create",
express.raw({ type: "application/json" }),
verifyWebhook
);
2. Assuming browser rules apply to Shopify servers
They don’t. Shopify doesn’t care about your Access-Control-Allow-Origin header when sending webhooks.
3. Adding wildcard CORS everywhere
This often starts as a dev shortcut and ends up in production:
app.use(cors({ origin: "*" }));
That’s fine for some truly public read-only APIs, but it’s usually wrong for Shopify app backends.
4. Forgetting that security headers are separate concerns
CORS is not a general-purpose security header. It controls browser access to responses.
If you’re hardening your app, you’ll also want headers like CSP, X-Content-Type-Options, and Referrer-Policy. If you’re working through CSP specifically, csp-guide.com is a solid reference.
A practical route layout
This is the route split I recommend for most Shopify apps:
/webhooks/* -> no CORS, raw body, HMAC verification
/api/* -> CORS enabled for known frontend origins
/auth/* -> usually same-origin or tightly restricted CORS
/admin/* -> often no public cross-origin access at all
That separation keeps your intent obvious.
Final takeaway
If you remember one thing, make it this:
Shopify webhooks do not need CORS because they are not browser requests.
Use CORS only for the parts of your Shopify app that are actually called from frontend JavaScript. Keep webhook endpoints out of that policy, verify HMAC using the raw body, and don’t paper over unrelated bugs by spraying Access-Control-Allow-Origin: * across your whole server.
That fix feels good for about five minutes, then it becomes tomorrow’s security debt.