Designing API CORS Headers: When to Allow Cross-Origin Requests and When to Refuse
CORS is the browser policy that controls which origins can call your API from JavaScript. Get it wrong in the permissive direction and you create CSRF vulnerabilities; get it wrong in the restrictive direction and customers cannot integrate from browsers at all.
Cross-Origin Resource Sharing (CORS) is a browser-enforced policy that controls which origins are allowed to make cross-site JavaScript requests against your API. It is one of the more frequently misunderstood pieces of web infrastructure, partly because the rules are subtle, partly because the failure modes are silent (a missing header just makes the browser refuse the response without any explanation visible to the application code), and partly because the security implications run in both directions.
The wrong-permissive failure mode is allowing any origin via Access-Control-Allow-Origin: * on endpoints that should be authenticated. This effectively turns the API into a CSRF source where any malicious site can make requests with the browser's session cookies. The wrong-restrictive failure mode is omitting CORS headers entirely on endpoints that customer browser code needs to call, which blocks legitimate integrations without making the problem obvious in API logs.
What CORS actually does
CORS is purely a browser-side policy. It does not protect the server from anything; a non-browser client (curl, Python requests, a server-to-server integration) ignores CORS headers entirely. What CORS does is constrain what browser JavaScript can read from cross-origin responses. By default, browsers refuse to expose response bodies to JavaScript when the request was cross-origin, unless the response includes explicit Access-Control-Allow-Origin headers naming the requesting origin (or wildcard).
The mechanism has two phases. Simple requests (GET, POST with a small set of content types, no custom headers) are sent directly, and the browser checks the response headers before exposing the body. Preflight requests (anything else, including any request with Authorization header or custom headers) are preceded by an OPTIONS request that the server must answer with Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers indicating that the actual request will be permitted.
The three policy patterns
For most B2B SaaS APIs, three CORS patterns cover essentially every case.
The first is server-to-server only. The API expects to be called from the customer's backend with a long-lived API key, never from browser JavaScript. The right CORS policy is to omit CORS headers entirely or explicitly refuse OPTIONS preflights with a 403. Customers who try to call the API from browser JavaScript will hit CORS errors and switch to server-side calls, which is the intended behavior.
The second is browser-callable with public data. Some endpoints (status pages, public documentation, marketing site forms) are meant to be embedded in customer pages and called from anywhere. The right CORS policy is Access-Control-Allow-Origin: * with no credentials, which lets any browser read the response but does not pass cookies or HTTP authentication.
The third is browser-callable with authentication. Some endpoints are meant to be called from a known set of customer dashboards or web apps using session tokens. The right CORS policy is to echo the request Origin header back in Access-Control-Allow-Origin when it matches an allowlist, plus Access-Control-Allow-Credentials: true. The wildcard is not allowed in combination with credentials.
The wildcard-with-credentials trap
The most common CORS mistake is the combination of Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. Browsers refuse to honor this combination as a defense against the misconfiguration, so the customer sees the same CORS error as if credentials were not allowed at all. The fix is either to drop credentials (if the endpoint truly is public) or to echo specific origins from an allowlist.
The echo pattern requires care. A naive implementation that just sets Access-Control-Allow-Origin: $request_origin for any incoming Origin is equivalent to * from a security perspective and is exactly the CSRF vulnerability the wildcard-with-credentials prohibition is trying to prevent. The correct pattern is to maintain an explicit allowlist and to echo only origins that appear in it.
The preflight cache
Preflight requests are expensive: they double the round-trip count for every actual request that triggers them. The Access-Control-Max-Age header tells the browser how long to cache the preflight result. The right value depends on how stable the CORS policy is; for stable policies, 86400 seconds (24 hours) is a reasonable upper bound that browsers honor. Some browsers cap this lower (Chrome caps at 7200 seconds, Firefox at 86400) but the header is still useful.
What triggers preflight is worth knowing: any request with Authorization header, custom headers, non-simple content types (anything other than text/plain, application/x-www-form-urlencoded, or multipart/form-data), or methods other than GET/HEAD/POST. Most authenticated API calls trigger preflight and benefit from caching.
The Vary header obligation
When the CORS response varies by request Origin (as in the echo pattern), the response must include Vary: Origin. Otherwise CDNs and other intermediate caches may serve a response with one customer's Origin header to a request from a different customer's origin, causing intermittent CORS failures that are hard to reproduce. The Vary header is a load-bearing detail that is easy to miss.
The same Vary obligation applies to Access-Control-Allow-Credentials and Access-Control-Allow-Methods if those vary by Origin or other request properties. The general rule is that any response header whose value depends on a request header must be reflected in Vary.
The exposed-headers question
By default, browser JavaScript can read only a small set of response headers (Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma) from cross-origin responses. To expose custom headers (X-Request-ID, X-RateLimit-Remaining, ETag, etc.), the response must include Access-Control-Expose-Headers: X-Request-ID, X-RateLimit-Remaining, ETag.
This is one of the more common missing pieces in API CORS configuration: the headers are sent correctly but browser JavaScript cannot read them, so customer integrations cannot implement client-side rate-limit tracking or conditional-request support. The fix is to enumerate every custom header the API exposes in Access-Control-Expose-Headers.
Three patterns that fail
First, the wildcard-with-credentials misconfiguration discussed above. Customers report CORS errors and the support response is often to disable CSRF protection on their backend, which is the wrong direction.
Second, missing OPTIONS handler. Many web frameworks require explicit OPTIONS route registration, and an API that has POST /endpoint but no OPTIONS /endpoint will return 405 Method Not Allowed for preflight, which the browser treats as CORS failure. The fix is to ensure OPTIONS is handled for every CORS-relevant route, typically via middleware that responds to all OPTIONS requests with the appropriate headers and a 204 status.
Third, CORS-on-error inconsistency. Some implementations apply CORS headers only on successful responses, so an error response (404, 500) is missing the Access-Control-Allow-Origin header, the browser refuses to expose the response body to JavaScript, and the customer sees a generic CORS error instead of the actual error message. The fix is to apply CORS headers in middleware that runs on every response regardless of status.
Our pattern
Our four products (DocuMint, CronPing, FlagBit, WebhookVault) all default to server-to-server-only CORS for authenticated endpoints, with explicit Access-Control-Allow-Origin: * only on the demo endpoints that are designed to be called from anywhere without authentication. The demo endpoints have separate rate limiting (60 requests per minute per IP) and return only ephemeral data, which keeps the wildcard safe.
The dashboard surface (where logged-in customers manage their account and resources) is not yet implemented as a separate frontend, so we have not had to deal with the echo-origin-with-credentials case. When we add a dashboard, we will need to set up a tight allowlist of dashboard origins and the Vary: Origin header to keep CDN behavior correct.
The deeper observation is that CORS is a policy enforced by one party (the browser) on behalf of another (the API server), with a third party (customer JavaScript) bearing the consequence of misconfiguration. The asymmetry produces failure modes where the API server thinks it is operating correctly because its logs look normal, while customers experience broken integrations they cannot diagnose. The discipline is to test CORS behavior from an actual browser context as part of API release validation, not just from curl which ignores CORS entirely.
Our products: DocuMint (PDF invoice generation API), CronPing (cron job monitoring with status pages), FlagBit (feature flags API for modern teams), and WebhookVault (webhook capture and replay) keep the lights on.