Stripe webhooks and CORS get mixed together constantly, and that usually means someone is solving the wrong problem.
I’ve seen this play out the same way more than once: payments work in Stripe Checkout, the webhook endpoint is live, then somebody opens DevTools, sees a failed browser request, and starts adding Access-Control-Allow-Origin: * to the webhook route. A few commits later, webhook signature verification breaks, preflight requests start showing up where they never mattered, and the team is less sure than before what CORS even applies to.
Here’s the core truth:
Stripe webhooks are server-to-server. Browsers are not involved. CORS does not protect, enable, or block Stripe’s webhook delivery.
That sounds obvious when stated plainly, but real projects blur this line because the same app often has:
- a browser frontend
- an API backend
- a Stripe webhook endpoint
- maybe a “test webhook” page in the admin UI
That’s where the confusion starts.
The setup that went wrong
A SaaS team had this architecture:
- React frontend on
https://app.example.com - API on
https://api.example.com - Stripe webhook endpoint at
https://api.example.com/webhooks/stripe
They wanted an internal admin page to “retry webhook processing” from the browser for testing. Somebody pointed that browser tool directly at /webhooks/stripe.
Then the browser complained about CORS.
So they “fixed” it like this:
app.use("/webhooks/stripe", (req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Stripe-Signature");
if (req.method === "OPTIONS") {
return res.status(204).end();
}
next();
});
That looked reasonable. It was also the wrong fix.
Why this broke things
Stripe signs the raw request body. If your middleware parses or rewrites it before verification, signature validation can fail.
This team had also mounted JSON parsing globally:
app.use(express.json());
Then their webhook handler did this:
app.post("/webhooks/stripe", async (req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(
JSON.stringify(req.body),
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// process event
res.json({ received: true });
});
Classic bug.
constructEvent() expects the original raw payload, not a reconstructed JSON string. Adding CORS headers didn’t help. Handling OPTIONS didn’t help. The browser was never the real webhook client anyway.
What was actually happening
There were really two different use cases:
-
Stripe → server webhook delivery
No browser. No CORS relevance. -
Admin browser → internal testing endpoint
Browser involved. CORS applies if cross-origin.
The mistake was trying to make one endpoint serve both jobs.
That’s the pattern I’d avoid every time.
Before: one overloaded endpoint
Here’s the broken shape:
app.use(express.json());
app.use("/webhooks/stripe", (req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Stripe-Signature");
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
app.post("/webhooks/stripe", (req, res) => {
const sig = req.headers["stripe-signature"];
const event = stripe.webhooks.constructEvent(
JSON.stringify(req.body),
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
// ...
res.sendStatus(200);
});
Problems here:
- JSON body parsing runs too early
- CORS is being added to a route Stripe calls directly
- Browser testing and Stripe delivery share one endpoint
Stripe-Signatureis treated like a browser concern
That last point matters. Browsers don’t magically get to send whatever headers they want. Custom headers often trigger preflight. But Stripe’s webhook request is not a browser request, so none of that is relevant to Stripe sending Stripe-Signature.
After: separate the webhook from browser tooling
The fix was simple and boring, which is usually a good sign.
1. Keep the real Stripe webhook server-only
const express = require("express");
const Stripe = require("stripe");
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// Regular API routes can use JSON parsing
app.use("/api", express.json());
// Stripe webhook must use raw body
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error("Stripe webhook signature failed:", err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
switch (event.type) {
case "checkout.session.completed":
// fulfill order
break;
case "invoice.paid":
// update subscription state
break;
default:
console.log(`Unhandled event type ${event.type}`);
}
res.json({ received: true });
}
);
No CORS headers. No OPTIONS handling. No browser assumptions.
That endpoint is for Stripe and your server only.
2. Create a separate browser-facing test endpoint
If your admin UI needs to trigger some internal replay or simulation flow, make a different route:
const cors = require("cors");
app.use(
"/admin-api",
cors({
origin: "https://app.example.com",
methods: ["POST"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
}),
express.json()
);
app.post("/admin-api/stripe/replay", async (req, res) => {
// auth check here
// load a stored event or trigger internal processing
res.json({ ok: true });
});
Now CORS is used where it actually belongs: a browser calling a cross-origin API.
The “after” architecture
This split cleaned everything up:
/webhooks/stripe→ Stripe only, no CORS/admin-api/stripe/replay→ browser admin tool, CORS enabled selectively
That also made security review easier. When I look at webhook endpoints, I want them as narrow and dumb as possible:
- accept POST
- read raw body
- verify signature
- process event
- return quickly
No browser compatibility layer. No wildcard origin. No extra headers unless there’s a very specific reason.
What CORS headers should look like for browser APIs
A lot of teams copy-paste examples without checking what a mature API actually exposes.
For reference, 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, Warning
That’s a good real-world reminder that CORS is often more than just Access-Control-Allow-Origin. If your frontend needs to read response metadata like rate-limit headers or pagination links, you may also need Access-Control-Expose-Headers.
For a Stripe admin dashboard endpoint, a browser client might need something like:
app.use(
"/admin-api",
cors({
origin: "https://app.example.com",
exposedHeaders: ["X-Request-Id", "X-RateLimit-Remaining"],
credentials: true,
})
);
But again, not on the webhook route.
A quick test that saved time
One thing I like to do when debugging CORS is verify the response headers outside the app code path I think is failing. It helps separate “browser policy problem” from “route logic problem.” Tools like HeaderTest are useful for checking exactly what headers your endpoint returns before you keep changing middleware blindly.
That matters because webhook failures often get mislabeled as CORS when the real issue is one of these:
- invalid Stripe signature
- wrong webhook secret
- parsed body instead of raw body
- endpoint returning 302/403/500
- local tunnel misconfiguration
- app firewall or proxy modifying the request
Next.js example: the same rule applies
I see this bug a lot in Next.js because developers are used to browser/API routes living close together.
Wrong approach
// pages/api/webhooks/stripe.ts
import Cors from "cors";
export default async function handler(req, res) {
res.setHeader("Access-Control-Allow-Origin", "*");
if (req.method === "OPTIONS") {
return res.status(204).end();
}
// req.body already parsed depending on config
// signature verification may fail here
}
Better approach
// pages/api/webhooks/stripe.ts
import { buffer } from "micro";
import Stripe from "stripe";
export const config = {
api: {
bodyParser: false,
},
};
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-05-28.basil",
});
export default async function handler(req, res) {
if (req.method !== "POST") {
return res.status(405).end();
}
const sig = req.headers["stripe-signature"];
const buf = await buffer(req);
let event;
try {
event = stripe.webhooks.constructEvent(
buf,
sig!,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
res.status(200).json({ received: true });
}
If you need browser-triggered admin actions, put those in another API route and add CORS there if the frontend origin differs.
Security takeaways
My rule is simple:
Webhook endpoints are not public browser APIs. Treating them like one creates bugs.
A few practical habits help:
- Don’t add CORS headers to webhook routes by default
- Don’t parse Stripe webhook bodies as JSON before verification
- Don’t let browser testing requirements shape your webhook contract
- Do separate admin/testing endpoints from real inbound webhooks
- Do restrict CORS origins on actual browser-facing routes
- Do review adjacent headers too if you’re exposing internal APIs; if you’re tightening overall response security, csp-guide.com is worth a read for the browser-side policy layer beyond CORS
The team in this case study ended up with fewer moving parts, cleaner logs, and no more phantom “CORS issue” tickets around Stripe. Once they split the endpoints, the problem stopped being mysterious.
That’s usually the sign you finally fixed the right thing.