GitHub webhooks and CORS get mixed together constantly, and that usually leads to the wrong architecture.

Here’s the blunt version:

GitHub webhooks do not need CORS. Browsers need CORS. GitHub’s webhook delivery system is server-to-server HTTP.

If GitHub is POSTing an event to your endpoint, CORS is irrelevant because no browser is enforcing cross-origin restrictions. The browser is the thing that cares about Access-Control-Allow-Origin, preflights, and exposed headers. GitHub’s webhook infrastructure does not.

Where people get stuck is one of these cases:

  1. They try to receive GitHub webhooks directly in frontend code.
  2. They try to call the GitHub API from a browser after a webhook event.
  3. They build a dashboard that fetches webhook-related data cross-origin.
  4. They confuse GitHub API CORS with GitHub webhook delivery.

I’ve seen teams burn hours tweaking CORS headers on a webhook endpoint that GitHub was calling just fine. Wrong layer, wrong problem.

The mental model

Keep these two flows separate:

Flow 1: GitHub webhook delivery

  • GitHub sends POST /webhook
  • Your server receives JSON
  • Your server verifies the signature
  • Your server processes the event

This is not a browser request, so CORS does not apply.

Flow 2: Browser calling your app or GitHub API

  • Frontend app at https://app.example.com
  • API at https://api.example.com
  • Or frontend calling https://api.github.com

This is a browser request, so CORS does apply.

That distinction is the whole game.

What GitHub’s API actually sends for CORS

GitHub’s API does support cross-origin browser access in some cases. Real headers from api.github.com include:

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 tells you two useful things:

  1. GitHub allows cross-origin reads broadly with access-control-allow-origin: *
  2. GitHub explicitly exposes useful non-simple headers like ETag and rate-limit headers to browser JavaScript

That’s for the GitHub API, not webhook delivery.

Why you can’t handle webhooks in the browser

A webhook receiver must be a public HTTP endpoint GitHub can reach. Frontend JavaScript in a browser is not a stable webhook target.

This won’t work:

// This is fantasy. GitHub cannot "POST into your React app".
window.addEventListener('github-webhook', (event) => {
  console.log(event.detail);
});

Browsers don’t expose a public callback URL that GitHub can invoke. Even if you hack together something with a local tunnel during development, production webhooks belong on a server.

And even if a browser somehow got the payload, you’d still have a worse problem: signature verification secrets do not belong in frontend code.

The correct architecture

Use this shape:

GitHub -> Your backend webhook endpoint -> Queue / app logic / database
                                      \
                                       -> Your frontend polls or subscribes to your backend

Your frontend can then call your own backend with CORS configured as needed.

A proper GitHub webhook receiver in Node.js

Here’s a minimal Express receiver that verifies X-Hub-Signature-256.

import express from "express";
import crypto from "crypto";

const app = express();
const port = 3000;
const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET;

// GitHub signature verification needs the raw body
app.use(
  express.raw({
    type: "application/json",
  })
);

function verifyGitHubSignature(req, secret) {
  const signature = req.header("x-hub-signature-256");
  if (!signature) return false;

  const hmac = crypto.createHmac("sha256", secret);
  const digest = "sha256=" + hmac.update(req.body).digest("hex");

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(digest)
    );
  } catch {
    return false;
  }
}

app.post("/github/webhook", (req, res) => {
  if (!verifyGitHubSignature(req, webhookSecret)) {
    return res.status(401).send("Invalid signature");
  }

  const event = req.header("x-github-event");
  const deliveryId = req.header("x-github-delivery");
  const payload = JSON.parse(req.body.toString("utf8"));

  console.log("GitHub event:", {
    event,
    deliveryId,
    action: payload.action,
    repository: payload.repository?.full_name,
  });

  res.status(200).send("OK");
});

app.listen(port, () => {
  console.log(`Listening on port ${port}`);
});

Notice what’s missing: no CORS middleware. None is required for GitHub to deliver the webhook.

When CORS does matter around webhooks

Let’s say you build a frontend dashboard that shows the latest webhook deliveries.

Your browser app:

  • Origin: https://dashboard.example.com

Your API:

  • Origin: https://api.example.com

Now you need CORS on your API, not on GitHub’s webhook POST.

Express example with strict CORS

import express from "express";
import cors from "cors";

const app = express();

app.use(express.json());

app.use(
  "/api",
  cors({
    origin: "https://dashboard.example.com",
    methods: ["GET", "POST"],
    allowedHeaders: ["Content-Type", "Authorization"],
    exposedHeaders: ["ETag", "X-Request-Id"],
    credentials: true,
  })
);

app.get("/api/webhook-events", (req, res) => {
  res.setHeader("ETag", '"events-v1"');
  res.setHeader("X-Request-Id", "req_123");
  res.json([
    {
      id: "delivery-1",
      event: "push",
      repository: "acme/project",
    },
  ]);
});

app.listen(3000);

If your frontend uses fetch(..., { credentials: "include" }), don’t use Access-Control-Allow-Origin: *. That combination is invalid. Use the exact allowed origin.

Browser call to GitHub API: what works

A frontend app can call some GitHub API endpoints directly because GitHub sends:

access-control-allow-origin: *

And GitHub exposes headers such as:

  • ETag
  • Link
  • Retry-After
  • X-RateLimit-Limit
  • X-RateLimit-Remaining
  • X-RateLimit-Reset
  • X-OAuth-Scopes
  • X-GitHub-Request-Id

That means browser JavaScript can read them.

Example:

async function loadRepo() {
  const res = await fetch("https://api.github.com/repos/octocat/Hello-World");

  console.log("status", res.status);
  console.log("etag", res.headers.get("etag"));
  console.log("rate remaining", res.headers.get("x-ratelimit-remaining"));
  console.log("request id", res.headers.get("x-github-request-id"));

  const data = await res.json();
  console.log(data.full_name);
}

That is unrelated to webhook receiving, but people often combine them in the same app.

A common pattern: relay webhook data to the frontend

Your backend receives the webhook, stores a normalized event, and your frontend fetches it.

Backend

app.post("/github/webhook", (req, res) => {
  if (!verifyGitHubSignature(req, webhookSecret)) {
    return res.status(401).send("Invalid signature");
  }

  const event = req.header("x-github-event");
  const payload = JSON.parse(req.body.toString("utf8"));

  const record = {
    id: crypto.randomUUID(),
    event,
    repo: payload.repository?.full_name,
    action: payload.action ?? null,
    createdAt: new Date().toISOString(),
  };

  // Save to DB in real code
  globalThis.events = globalThis.events || [];
  globalThis.events.unshift(record);

  res.status(200).send("OK");
});

app.get("/api/events", (req, res) => {
  res.json(globalThis.events || []);
});

Frontend

async function loadEvents() {
  const res = await fetch("https://api.example.com/api/events", {
    credentials: "include",
  });

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}`);
  }

  return res.json();
}

loadEvents()
  .then(events => {
    console.log(events);
  })
  .catch(err => {
    console.error(err);
  });

Again, CORS belongs on /api/events, not on GitHub’s webhook delivery.

Preflight gotchas

If your frontend sends custom headers or non-simple methods, the browser may send an OPTIONS preflight.

Your backend has to answer it correctly.

Example manual handling without a library:

app.options("/api/events", (req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "https://dashboard.example.com");
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
  res.setHeader("Access-Control-Allow-Credentials", "true");
  res.status(204).end();
});

If preflight fails, your browser request fails before your actual route handler runs. That’s why people think “my API is broken” when really the browser blocked it.

Serverless example for GitHub webhooks

If you’re using a serverless platform, same rule: verify the webhook on the server side.

import crypto from "crypto";

export default async function handler(req, res) {
  if (req.method !== "POST") {
    return res.status(405).send("Method Not Allowed");
  }

  const rawBody = req.body; // platform-specific: make sure this is raw bytes/string
  const signature = req.headers["x-hub-signature-256"];
  const secret = process.env.GITHUB_WEBHOOK_SECRET;

  const digest =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBody).digest("hex");

  if (!signature || signature !== digest) {
    return res.status(401).send("Invalid signature");
  }

  const payload = JSON.parse(rawBody);
  return res.status(200).json({
    ok: true,
    event: req.headers["x-github-event"],
    repo: payload.repository?.full_name,
  });
}

One warning: many serverless frameworks parse JSON automatically, which can break signature verification if you lose the exact raw body. This is a real production footgun.

Security advice I’d actually enforce

A few rules I’d be strict about:

1. Never trust webhook payloads without signature verification

If you skip this, anyone can hit your endpoint and pretend to be GitHub.

2. Keep webhook secrets server-side only

No frontend code. No mobile app bundle. No excuses.

3. Don’t use wildcard CORS on authenticated app APIs

Access-Control-Allow-Origin: * is fine for fully public data. For authenticated dashboard APIs, lock it down.

4. Treat CORS as browser policy, not API auth

CORS is not an access control system. Non-browser clients ignore it completely.

5. Log delivery IDs

GitHub sends a delivery identifier. Save it. Debugging duplicates and retries gets much easier.

The clean way to think about it

If the request comes from GitHub’s servers, think:

  • public HTTPS endpoint
  • raw body handling
  • signature verification
  • no CORS needed

If the request comes from browser JavaScript, think:

  • origin matching
  • preflight
  • exposed headers
  • credentials rules

That’s the whole split.

For official docs, check GitHub’s webhook documentation and GitHub REST API documentation. If you’re hardening the rest of your response headers too, the official MDN docs are useful, and for a practical security-header reference beyond CORS I sometimes point people to CSP Guide.