CORS Explained for Backend Engineers: Preflight, Credentials, and Security Pitfalls
CORS Explained for Backend Engineers covers origins, preflight, and credentials. Learn which headers to set, which to avoid, and how browsers enforce them.
CORS Explained for Backend Engineers is the browser rule set that decides if a page on one site may read a response from your API. It matters because a wrong allow header can expose data to a page you do not own. A header that is too strict will break a real app and tempt people to open the policy wide. You want a short allow list and a clear cookie story.
If you think CORS is an auth system, you will skip real checks. Then a script that is not a browser will ignore the headers and call you anyway. Also, a reflected origin with credentials is a data leak waiting for a bad page.
What CORS is and why it fails
Browsers isolate sites with the same origin policy. Origin means scheme, host, and port. CORS is the opt in that lets your API say a specific origin may read a response. Because the browser enforces it, the server must still decide who the caller is.
In my experience, the failure starts with a wildcard copied from a tutorial. However, a star origin cannot be used with credentialed calls. As a result, someone reflects the request Origin header instead. That turns any site into an allowed caller if cookies are sent.
The second failure is to treat a preflight success as a login. A preflight only asks if the cross origin call is allowed by policy. It does not prove the user, the app, or the intent. Your handler must still check auth on the real request.
Simple requests and preflight
Some calls skip the extra check. A simple request uses a basic verb and a short header set. First, the browser sends the real call.
Next, it exposes the response to the page only if your allow origin matches. If the call uses JSON content type, a custom header, or a verb like PUT, the browser sends a preflight first.
The preflight is an OPTIONS call. It names the verb and headers the page wants to use. You answer with the allows, a max age, and no sensitive body.
Still, you should not run business logic on OPTIONS. Also, do not require a user token on the preflight if the browser will not send it yet.
Architecture and a safe server setup
Keep the policy in one place. A gateway can add CORS headers, or the app can. If both do, you will emit two values and the browser will fail the call.
Pick one owner. I prefer the app when the allow list depends on the route, and the gateway when every route shares one list.
A common mistake I have seen is to allow credentials on every route, including public reads. Then any allowed origin can ride the user cookie. Specifically, split routes.
Cookie routes get a tight origin list. Public JSON can use a star only if you never send credentials and the data is meant to be public.
Store allowed origins in config, not in code branches scattered across handlers. For example, a web app origin and an admin origin can be two entries. Review adds like you review a new public route. Wildcards inside host names are easy to get wrong, so prefer exact origins.
Steps to put a policy in place
- List the real front end origins, including scheme and port.
- Decide which routes need cookies or auth headers.
- Answer OPTIONS with the allow headers and no side effects.
- Set Vary to Origin when the allow value depends on the caller.
- Test a denied origin and expect the browser to hide the body.
When the list is empty, fail closed and allow nothing cross origin. After you add an origin, test login and a write from that app only. Although local dev needs a localhost origin, do not ship that entry to production.
Trade-offs in the header choices
A tight list is safer and needs more release work when a front end domain changes. A wide list is easy and risky, especially with cookies. You should choose per route class, not once for the whole company.
| Header choice. | Use it when. | Main risk. | Browser result. |
|---|---|---|---|
| Exact origin. | You know the front end host. | A new host fails until you add it. | That host may read the body. |
| Star origin. | Data is public and has no cookies. | You cannot send credentials. | Any page may read the body. |
| Reflected origin. | Almost never for credentialed APIs. | Any site becomes allowed. | The caller origin is echoed. |
| Credentials on. | The browser must send cookies. | CSRF style calls if cookies are loose. | Cookies go on the cross origin call. |
| Short max age. | You change policy often. | More preflight traffic. | The browser repeats OPTIONS. |
If you need cookies, echo only an origin that is on the list, and set the credentials flag. If you do not need cookies, do not set the credentials flag at all. Instead, use a bearer token that the page stores with care, and still check it on the server.
Max age caches the preflight. A long cache makes policy rollback slow. A short cache adds OPTIONS load. Therefore use a modest cache, and keep a way to change policy without waiting a full day.
Pitfalls and failure modes
Reflecting the Origin header is the pitfall I see most. The code checks that Origin is present, then copies it into the allow header, then turns credentials on. Any website can then ask the browser to call you with the user cookie and read the JSON. Allow only names you listed.
Null origin shows up for some sandboxed or file cases. If you allow the literal null origin, you allow a class of odd pages you do not control. Do not put null on the production list.
Also, do not treat a missing Origin as a browser pass. Non browser clients omit it, and they must still pass auth.
Header allow lists drift. A new client header triggers a preflight failure, and someone sets the allow to a star. That star is broader than the one header you needed.
Name the headers you use, such as content type and your auth header. Reject the rest.
Caches and proxies
If a shared cache stores a response that was allowed for one origin, it might serve it to another. Set Vary to Origin whenever the allow header depends on the request. Also, do not cache credentialed responses in a shared cache. A private browser cache is a different case, and you should still send the right cache headers.
- Allow origin echoes the request with no list check.
- Credentials flag set on a public route.
- Localhost left in the production origin list.
- Two layers both write CORS headers.
- OPTIONS handler writes or deletes data.
CORS does not stop a caller that is not a browser. A server side job can POST to you with no preflight at all. That is why auth, CSRF protection, and input checks still matter. The browser rule is only for browser reads.
We once hit a bottleneck when a long preflight max age hid a bad policy change. Clients kept the old allows for hours, so a rollback looked like it did nothing. We shortened the cache and versioned the policy in logs. Support could then tell a stale browser from a bad deploy.
A config you can adapt
The snippet shows a small allow list for one web origin. It answers preflight with no side effects. It sets credentials only because this API uses cookies.
Change the origin before you use it. Also, add Vary so caches do not mix callers.
# Illustrative CORS policy for a cookie based API.
# Allow one exact origin. Do not reflect arbitrary origins.
map $http_origin $cors_ok {
default 0;
"https://app.example.com" 1;
}
server {
listen 443 ssl;
server_name api.example.internal;
location /v1/ {
if ($cors_ok = 1) {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary "Origin" always;
}
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Methods "GET, POST" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age "600" always;
return 204;
}
proxy_pass http://app;
}
}
This proxy form is easy to get wrong if the if blocks surprise you. Prefer the same logic in app code if your team reviews that more carefully. The point is the list, the credentials flag, and a preflight that returns before the app writes.
The MDN CORS guide documents which requests trigger a preflight and which headers the browser checks. RFC 6454 defines origin. Use those when a framework default disagrees with what you see in the browser.
Performance, scale, and cost
Preflight adds a round trip before the real call. On a chatty client that can double the request count. Therefore cache preflight with a modest max age, and avoid custom headers you do not need. Each extra allowed header is a contract you must support.
OPTIONS should be the cheapest route you have. Do not hit the database. Do not log full headers at debug level on every call.
Sample preflight logs, and alert on a spike in denied origins. That spike is often a bad deploy or a new front end host.
At scale, generate the allow decision from a small in memory set. A remote call to decide CORS is wasted work. Also, keep the policy file tiny so every gateway replica loads the same list. Drift between regions shows up as random browser bugs.
Where CORS sits with other controls
CORS does not replace XSS prevention. If an attacker can run script on your own origin, same origin rules will not save the API. Encode output and set a content policy. Also, review cookie scope so a sibling host is not trusted by accident.
A web application firewall can block abusive browsers, but it should not be the only place you set allow origin. Keep the policy in the service you review. Serve the API only over TLS so the origin scheme stays https. The Fetch standard CORS section is the detailed behavior browsers follow.
Cost is mostly extra requests and support time, not a license. Budget for preflight traffic in your rate limits so OPTIONS does not trip the same cap as writes. A separate cheap bucket for preflight keeps a policy check from looking like an attack.
Key Takeaways
- CORS lets a named origin read responses. It is not login.
- Allow exact origins when cookies or credentials are in use.
- Do not reflect the request Origin unless it is on your list.
- Answer preflight with no side effects and a short header list.
- Set Vary to Origin when the allow value changes per caller.
- Keep a single owner so two layers do not both set headers.
- Still authenticate the real request. Non browsers skip CORS.
FAQ
Can you use a star origin with cookies?
No. Browsers reject a star allow origin when credentials are on. If you need cookies, list exact origins. If the data is public, drop credentials and then a star can be valid.
Why does the browser send OPTIONS?
The call is not a simple request. A JSON content type, a custom header, or a less common verb triggers a preflight. Answer it with the methods and headers you allow. Do not change data on that call.
Should the API allow every subdomain?
No. A loose subdomain pattern will trust hosts you forgot you owned. List exact origins. Add a new front end with a config change you can review and roll back.
What if a mobile app calls the API?
Native apps are not browsers, so CORS is not their guard. Use the normal auth checks. You may still set CORS for the web client that shares the same API. Do not weaken auth because a native client has no Origin header.
CORS Explained for Backend Engineers comes down to a short list and a clear cookie rule. Write the production origins for your API today. Remove any star, null, or reflected origin on routes that use credentials.
Next, make OPTIONS cheap and free of side effects. Then set Vary and test one allowed origin and one denied origin in a real browser. After that, confirm the handler still checks auth when the browser is not in the path.
Last updated on 07 September 2026.