Webhook signature verification and CORS get mixed up all the time, usually in bad ways.
The short version: webhook verification with HMAC should almost always happen server-side, and CORS is only relevant if a browser is calling your verification endpoint. A webhook provider like GitHub, Stripe, or Slack is not a browser. It does not care about Access-Control-Allow-Origin.
That distinction saves a lot of confusion.
The mental model
There are really two separate flows:
-
Webhook delivery
- Provider sends an HTTP request to your server
- Your server verifies the HMAC signature
- No browser involved
- No CORS involved
-
Browser calling your app
- Frontend JavaScript sends a request to your backend
- Browser enforces CORS
- Your backend may verify an HMAC for some app-specific reason
- CORS absolutely matters
If you remember one thing, make it this:
CORS protects browsers. HMAC protects message integrity and authenticity.
They solve different problems.
When CORS actually matters for webhook verification
CORS matters when you build something like this:
- a dashboard that lets a developer paste webhook payload + signature and test verification in the browser
- a browser app that forwards signed events to your backend for validation
- a local debugging tool running on
http://localhost:3000 - an admin UI that replays captured webhook events
In those cases, the browser is making a cross-origin request to your verification endpoint, so you need proper CORS headers.
The most common architecture
This is the one I recommend:
-
/webhooks/provider
Receives real webhooks from the provider. No CORS config needed. -
/api/verify-webhook
Optional internal endpoint used by your frontend tooling or admin UI. CORS may be needed here.
Keep them separate. Don’t bolt browser CORS behavior onto your real webhook ingestion route unless you actually need it.
Why preflight happens so often here
A browser sends a preflight OPTIONS request when your frontend uses:
Content-Type: application/json- custom headers like
X-Signature - methods other than simple GET/POST form patterns
Webhook verification UIs almost always use custom headers such as:
X-Hub-Signature-256Stripe-SignatureX-Slack-Signature
That means preflight is normal.
Minimal CORS response for a verification endpoint
If your frontend at https://app.example.com calls https://api.example.com/api/verify-webhook, your API should respond like this:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, X-Hub-Signature-256
Access-Control-Max-Age: 600
Vary: Origin
If the frontend needs to read custom response headers, expose them explicitly:
Access-Control-Expose-Headers: X-Verification-Result, X-Request-Id
That part gets missed constantly.
Real-world header example
For comparison, 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
That’s a good reminder that readable response headers are not automatic. If your frontend needs to inspect X-Verification-Result, you must expose it.
Node.js example: proper HMAC verification endpoint with CORS
This example uses Express and verifies a GitHub-style HMAC signature.
import express from "express";
import crypto from "crypto";
const app = express();
const PORT = 8080;
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "dev-secret";
const ALLOWED_ORIGIN = "https://app.example.com";
// Keep raw body for HMAC verification
app.use("/api/verify-webhook", express.raw({ type: "*/*" }));
app.options("/api/verify-webhook", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", ALLOWED_ORIGIN);
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Hub-Signature-256");
res.setHeader("Access-Control-Max-Age", "600");
res.setHeader("Vary", "Origin");
res.status(204).end();
});
app.post("/api/verify-webhook", (req, res) => {
const origin = req.headers.origin;
if (origin === ALLOWED_ORIGIN) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Expose-Headers", "X-Verification-Result");
res.setHeader("Vary", "Origin");
}
const signature = req.header("X-Hub-Signature-256");
if (!signature) {
res.setHeader("X-Verification-Result", "missing-signature");
return res.status(400).json({ ok: false, error: "Missing signature" });
}
const rawBody = req.body;
const expected = "sha256=" + crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
const valid =
sigBuf.length === expBuf.length &&
crypto.timingSafeEqual(sigBuf, expBuf);
res.setHeader("X-Verification-Result", valid ? "valid" : "invalid");
if (!valid) {
return res.status(401).json({ ok: false, error: "Invalid signature" });
}
res.json({ ok: true });
});
app.listen(PORT, () => {
console.log(`Listening on http://localhost:${PORT}`);
});
Why this version is correct
A few things here matter a lot:
express.raw()preserves the exact request body bytes- HMAC is computed from the raw body, not parsed JSON
timingSafeEqual()avoids trivial timing leaks- CORS headers are returned only for the allowed browser origin
X-Verification-Resultis exposed so frontend code can read it
Browser example: calling the verifier
const payload = JSON.stringify({
action: "opened",
issue: { number: 42 }
});
const res = await fetch("https://api.example.com/api/verify-webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hub-Signature-256": "sha256=abc123"
},
body: payload
});
console.log("status", res.status);
console.log("verification", res.headers.get("X-Verification-Result"));
const data = await res.json();
console.log(data);
Without Access-Control-Expose-Headers: X-Verification-Result, that res.headers.get(...) call returns null even if the server sent the header.
That behavior trips people up constantly.
The wildcard trap
You might be tempted to use:
Access-Control-Allow-Origin: *
That’s fine for truly public, non-credentialed endpoints. GitHub does this for its API.
But for a webhook verification tool, I usually prefer a specific origin:
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Why? Because verification endpoints often deal with sensitive payloads, internal tooling, or developer-only workflows. Wildcard CORS is usually too loose.
Also, if you ever send credentials:
Access-Control-Allow-Credentials: true
then Access-Control-Allow-Origin: * is invalid. Browsers will reject it.
Credentialed requests example
If your admin UI relies on cookies or session auth:
Access-Control-Allow-Origin: https://admin.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Browser side:
await fetch("https://api.example.com/api/verify-webhook", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-Hub-Signature-256": "sha256=abc123"
},
body: payload
});
Don’t combine credentials: "include" with wildcard origin. That’s a dead end.
Serverless example
For serverless functions, explicit OPTIONS handling is usually enough.
import crypto from "crypto";
export default async function handler(req, res) {
const allowedOrigin = "https://app.example.com";
if (req.method === "OPTIONS") {
res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Hub-Signature-256");
res.setHeader("Access-Control-Max-Age", "600");
res.setHeader("Vary", "Origin");
return res.status(204).end();
}
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
res.setHeader("Access-Control-Expose-Headers", "X-Verification-Result");
res.setHeader("Vary", "Origin");
const rawBody = req.bodyRaw || Buffer.from(req.body || "");
const signature = req.headers["x-hub-signature-256"];
const secret = process.env.WEBHOOK_SECRET;
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const valid =
signature &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
res.setHeader("X-Verification-Result", valid ? "valid" : "invalid");
if (!valid) {
return res.status(401).json({ ok: false });
}
return res.status(200).json({ ok: true });
}
The exact raw-body access depends on your platform. That part matters more than people think.
Headers to allow vs headers to expose
I explain it like this:
- Allow-Headers: what the browser is allowed to send
- Expose-Headers: what frontend JavaScript is allowed to read
Example:
Access-Control-Allow-Headers: Content-Type, X-Hub-Signature-256
Access-Control-Expose-Headers: X-Verification-Result, X-Request-Id
Different jobs. Very easy to confuse.
Copy-paste CORS checklist for HMAC verification
Use this when the browser calls your verifier:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, X-Hub-Signature-256
Access-Control-Expose-Headers: X-Verification-Result
Access-Control-Max-Age: 600
Vary: Origin
Add this only if you need cookies or HTTP auth:
Access-Control-Allow-Credentials: true
Mistakes I see all the time
1. Parsing JSON before verifying HMAC
Bad:
app.use(express.json());
Then hashing JSON.stringify(req.body).
That can break verification because whitespace, key ordering, and raw bytes matter.
2. Adding CORS to the real webhook endpoint and thinking that fixes delivery
Webhook providers do not care about browser CORS policy.
3. Forgetting preflight
If you send X-Hub-Signature-256, the browser will likely preflight.
4. Forgetting Access-Control-Expose-Headers
Your frontend can’t read custom response headers otherwise.
5. Using * with credentials
Browsers reject that combo.
Recommended setup
My default setup looks like this:
-
Real webhook endpoint
- no CORS
- raw body verification
- strict signature validation
- IP or provider-level hardening if available
-
Browser-facing verification endpoint
- explicit allowed origin
OPTIONShandler- allowed custom signature header
- exposed result headers
- auth if this is internal tooling
If you’re also tightening the rest of your browser security posture, review your other headers too. For broader header guidance beyond CORS, see CSP Guide.
Final reference snippet
If you just want the bare minimum copy-paste policy for a browser-based HMAC verification endpoint:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, X-Hub-Signature-256
Access-Control-Expose-Headers: X-Verification-Result
Access-Control-Max-Age: 600
Vary: Origin
And if this is a real webhook receiver from a provider, you probably need exactly none of that. You need raw body handling and correct HMAC verification instead.