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:
- They try to receive GitHub webhooks directly in frontend code.
- They try to call the GitHub API from a browser after a webhook event.
- They build a dashboard that fetches webhook-related data cross-origin.
- 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:
- GitHub allows cross-origin reads broadly with
access-control-allow-origin: * - GitHub explicitly exposes useful non-simple headers like
ETagand 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:
ETagLinkRetry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-ResetX-OAuth-ScopesX-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.