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:

  1. Webhook delivery

    • Provider sends an HTTP request to your server
    • Your server verifies the HMAC signature
    • No browser involved
    • No CORS involved
  2. 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-256
  • Stripe-Signature
  • X-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-Result is 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.

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
    • OPTIONS handler
    • 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.