Webhook security and CORS get mixed together all the time, and that usually leads to one of two bad outcomes:

  1. people add CORS headers to webhook endpoints that never needed them
  2. people assume CORS protects webhook endpoints from abuse

It does neither.

Here’s the blunt version: CORS is a browser policy, not an authentication system, not an origin firewall, and definitely not webhook verification. If your payment provider, GitHub app, or internal service is sending server-to-server webhooks, CORS is usually irrelevant.

What does matter is knowing where CORS belongs in a webhook-based system, and where it absolutely does not.

The short rule

Use CORS for browser-facing endpoints in your webhook platform:

  • webhook management dashboards
  • “test webhook” buttons called from frontend code
  • browser apps that fetch delivery logs
  • admin UIs that replay failed events

Do not rely on CORS for incoming webhook receivers:

  • /webhooks/stripe
  • /webhooks/github
  • /webhooks/slack

Those endpoints need signature verification, authentication where possible, replay protection, rate limiting, input validation, and sane response handling.

Why CORS usually does nothing for incoming webhooks

A webhook sender is almost always a backend service, not a browser. Browsers enforce CORS. Servers do not.

If Stripe, GitHub, or your own worker posts JSON to your webhook URL, they are not blocked by missing Access-Control-Allow-Origin. They don’t care.

So this is useless for webhook security:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://github.com

That header does not mean “only GitHub can send requests here.” It only means “if a browser page from that origin makes a cross-origin request, the browser may expose the response.”

That’s why adding Access-Control-Allow-Origin: * to a webhook endpoint doesn’t magically make it insecure, and removing it doesn’t magically make it secure. For most webhook receivers, the browser should never be involved.

What actually secures webhook endpoints

For incoming webhooks, use this stack:

  • signature verification
  • timestamp checks
  • replay prevention
  • raw body verification
  • IP allowlisting only if the provider supports stable ranges
  • rate limiting
  • strict method and content-type validation

A basic Express webhook receiver should look more like this:

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

const app = express();

// Keep raw body for signature verification
app.post("/webhooks/github", express.raw({ type: "*/*" }), (req, res) => {
  const signature = req.header("X-Hub-Signature-256");
  const secret = process.env.GITHUB_WEBHOOK_SECRET;

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

  const valid =
    signature &&
    crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );

  if (!valid) {
    return res.status(401).send("invalid signature");
  }

  // Parse only after verification if needed
  const payload = JSON.parse(req.body.toString("utf8"));

  console.log("event:", req.header("X-GitHub-Event"));
  console.log("delivery:", req.header("X-GitHub-Delivery"));

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

app.listen(3000);

Notice what’s missing: CORS. On purpose.

When CORS does matter in a webhook system

CORS matters when your frontend talks to your backend about webhooks.

Common examples:

  • your React admin app lists webhook deliveries
  • your dashboard lets users rotate webhook secrets
  • your SPA triggers “redeliver event”
  • your frontend creates webhook subscriptions with fetch()

That traffic is browser-based, so CORS is relevant.

Good example: dashboard calling webhook admin API

If your dashboard lives at:

  • https://app.example.com

and your API lives at:

  • https://api.example.com

then your browser requests are cross-origin and need CORS.

A decent Express setup:

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

const app = express();

const allowedOrigins = [
  "https://app.example.com",
  "https://admin.example.com",
];

app.use("/api/webhooks", cors({
  origin(origin, callback) {
    // allow non-browser clients with no Origin header
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      return callback(null, true);
    }

    return callback(new Error("Not allowed by CORS"));
  },
  methods: ["GET", "POST", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization"],
  credentials: true,
  maxAge: 600,
}));

app.use(express.json());

app.get("/api/webhooks/deliveries", (req, res) => {
  res.json([{ id: "evt_123", status: "delivered" }]);
});

app.listen(3000);

That’s a lot better than the classic lazy config:

app.use(cors({ origin: true, credentials: true }));

I’ve seen that exact pattern ship to production and accidentally allow every reflected origin. Bad habit.

1. Scope CORS to browser-facing routes only

Don’t enable CORS globally unless every route needs it.

Bad:

app.use(cors());

Better:

app.use("/api/webhooks", cors(corsOptions));

Best:

app.get("/api/webhooks/deliveries", cors(corsOptions), handler);
app.post("/api/webhooks/:id/replay", cors(corsOptions), handler);

This reduces mistakes and keeps your actual webhook receivers clean.

2. Never use * with credentials

If your dashboard uses cookies or Authorization headers with browser requests, don’t pair that with wildcard origin.

Bad:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Browsers reject this combo anyway, but seeing it in production tells me nobody really tested the flow.

Use an explicit origin:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

Vary: Origin matters when responses are cached.

3. Allow only the methods and headers you need

For webhook admin APIs, keep it narrow.

Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization

Don’t throw in PUT, PATCH, OPTIONS, X-Requested-With, * just because some snippet on the internet did.

4. Handle preflight properly

If your frontend sends JSON or auth headers, browsers will often send an OPTIONS preflight request first.

Example:

OPTIONS /api/webhooks/evt_123/replay HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

Your server needs to answer with matching policy:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

5. Expose only response headers your frontend actually needs

By default, browsers won’t let JavaScript read most response headers from cross-origin responses.

If your webhook dashboard needs custom headers like retry info or request IDs, expose them explicitly.

GitHub is a good real-world example of this. api.github.com sends:

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 smart use of Access-Control-Expose-Headers: browser clients can read operational headers they genuinely need.

For your webhook API, maybe you only need this:

Access-Control-Expose-Headers: X-Request-Id, Retry-After

Keep it intentional.

6. Don’t confuse CORS errors with webhook delivery failures

A browser CORS error means frontend JavaScript could not read a response. It does not mean your backend never received the request.

This causes a lot of confusion in “test webhook” UIs. The browser may show a CORS failure while your server logs show the request arrived just fine.

When debugging, inspect actual headers and preflight behavior with a header checker like HeaderTest. It’s faster than guessing from a vague browser console message.

Copy-paste configs

Nginx for webhook dashboard API

location /api/webhooks/ {
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin https://app.example.com always;
        add_header Access-Control-Allow-Methods "GET, POST, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
        add_header Access-Control-Allow-Credentials true always;
        add_header Access-Control-Max-Age 600 always;
        add_header Vary Origin always;
        return 204;
    }

    add_header Access-Control-Allow-Origin https://app.example.com always;
    add_header Access-Control-Allow-Credentials true always;
    add_header Access-Control-Expose-Headers "X-Request-Id, Retry-After" always;
    add_header Vary Origin always;

    proxy_pass http://backend;
}

FastAPI for browser-facing webhook admin routes

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "DELETE"],
    allow_headers=["Content-Type", "Authorization"],
    expose_headers=["X-Request-Id", "Retry-After"],
    max_age=600,
)

@app.get("/api/webhooks/deliveries")
def list_deliveries():
    return [{"id": "evt_123", "status": "delivered"}]

What not to do on a webhook receiver

Don’t bolt on permissive CORS and call it security:

app.post("/webhooks/stripe", cors(), express.json(), (req, res) => {
  // unsafe: no signature verification
  res.send("ok");
});

That endpoint is still vulnerable to forged requests.

Do this instead:

app.post("/webhooks/stripe", express.raw({ type: "application/json" }), verifyStripeSignature, handler);

That’s the real control.

Final checklist

For incoming webhooks:

  • no need for CORS unless a browser truly calls the endpoint
  • verify signatures
  • use raw request body where required
  • reject unexpected methods and content types
  • add replay protection
  • log delivery IDs

For webhook dashboards and admin APIs:

  • allow only known origins
  • never use * with credentials
  • scope CORS to specific routes
  • keep methods and headers minimal
  • expose only the headers frontend code needs
  • return Vary: Origin

And if you’re tightening security beyond CORS, especially for browser-facing admin panels, pair it with sane cookie settings, CSP, and other response headers. If you need a refresher there, csp-guide.com is worth keeping open in another tab.

CORS is useful. It’s just wildly over-credited. For webhooks, treat it as a browser integration detail, not your security boundary.