Framework CORS Middleware Configuration
Almost nobody writes Access-Control-Allow-Origin by hand. In a real service the header is produced by a middleware component that ships with the framework or sits one pip install away, and the entire cross-origin policy of the API reduces to a handful of settings keys plus one decision about where that component sits in the request pipeline. That second half is where teams lose days: the settings are right, the package is installed, and the preflight still fails, because something above the CORS component answered the OPTIONS request first. This page is a sub-topic of Server-Side CORS Configuration & Header Management, and it maps the middleware layer across five stacks — Django, Spring Boot, FastAPI, Rails and Express — with the ordering rule, the allowlist key and the credential switch for each.
The WHATWG Fetch Standard does not know what a middleware stack is. It specifies a CORS-preflight fetch as a plain OPTIONS request carrying Origin and Access-Control-Request-Method, and requires a response whose Access-Control-Allow-Origin matches the request origin exactly when credentials are in play. Everything a framework adds — decorators, config blocks, filter chains — is a convenience for emitting that response. When the convenience misfires, the browser reports the same failure it would report for a bare socket server, which is why the fix is almost never “a different CORS package” and almost always “the same package, mounted earlier”.
Why Registration Order Decides the Outcome
A preflight is the least authenticated request your API will ever receive. The browser strips cookies from it, does not attach Authorization, sends no body and uses a method most routers do not have a handler for. Every generic guard in a middleware stack therefore has a reason to reject it before your route is reached. An authentication filter sees an anonymous caller and returns 401. A trailing-slash normaliser sees /api/orders and issues a 301 to /api/orders/. A body parser sees a request with no Content-Type and returns 415. None of those responses carry CORS headers, and every one of them fails the preflight.
The middleware pipeline is ordered, and the first component to write a complete response wins. Mounting the CORS component above everything else means it inspects the request while all of those guards are still downstream, answers the preflight itself, and never lets the guards see it. Mounting it below them means its configuration is correct and irrelevant.
There is one nuance that trips people who reason about this in terms of “first” and “last”: some frameworks number their stack from the outside in and some from the inside out. Django’s MIDDLEWARE list is outside-in, so index zero runs earliest on the request path. Starlette, and therefore FastAPI, wraps each newly added middleware around the previous one, so the component added last runs first. Rails exposes an explicit insert_before 0. Express is purely sequential in app.use order. The rule is always “earliest on the request path”, but the syntax that expresses it differs per stack.
Configuration Surface Reference
Each stack exposes the same four decisions — which component to register, where it must sit, how the allowlist is expressed, and how credentials are switched on — under different names.
| Stack | Component to register | Ordering rule | Origin allowlist key | Credential switch |
|---|---|---|---|---|
| Django | corsheaders.middleware.CorsMiddleware |
above CommonMiddleware and any authentication middleware |
CORS_ALLOWED_ORIGINS, CORS_ALLOWED_ORIGIN_REGEXES |
CORS_ALLOW_CREDENTIALS = True |
| Spring Boot | CorsFilter derived from a CorsConfigurationSource bean |
inside the security filter chain, before the authentication filter | CorsConfiguration.setAllowedOrigins / setAllowedOriginPatterns |
setAllowCredentials(true) |
| FastAPI | starlette.middleware.cors.CORSMiddleware |
added last, because Starlette wraps outward | allow_origins, allow_origin_regex |
allow_credentials=True |
| Rails | Rack::Cors |
insert_before 0 in the Rack stack |
origins inside an allow block |
credentials: true on the resource |
| Express | cors() from the cors package |
app.use before any router, auth guard or body parser |
origin option (string, array or function) |
credentials: true |
Step-by-Step Implementation
The worked example throughout is a single-page app served from https://app.orbital-crm.net calling an API at https://api.orbital-crm.net. The API uses cookie sessions, so credentials are on and a wildcard is not an option. Every snippet below produces the same wire behaviour; only the syntax changes.
1. Express with the cors package
Mount it as the very first app.use, before express.json() and before any authentication router. The origin option accepts a callback so the allowlist can be validated rather than reflected — the same pattern documented at length in Dynamic Origin Validation Patterns.
const express = require("express");
const cors = require("cors");
const ALLOWED = new Set(["https://app.orbital-crm.net", "https://admin.orbital-crm.net"]);
const app = express();
app.use(
cors({
origin(origin, callback) {
// No Origin header at all: server-to-server or curl, not a browser CORS call.
if (!origin) return callback(null, false);
return callback(null, ALLOWED.has(origin));
},
credentials: true,
methods: ["GET", "POST", "PATCH", "DELETE"],
allowedHeaders: ["Authorization", "Content-Type", "X-Request-Id"],
exposedHeaders: ["X-Request-Id", "X-RateLimit-Remaining"],
maxAge: 600,
optionsSuccessStatus: 204,
})
);
app.use(express.json());
app.use("/v1", require("./routes"));
app.listen(8080);
Two details matter. Passing false rather than an error to the callback makes the package omit Access-Control-Allow-Origin instead of throwing a 500, which keeps rejected origins quiet in the logs. And because preflightContinue defaults to false, the package answers OPTIONS itself and your routers never see it.
2. Nginx in front of the application
When the same service is also fronted by Nginx, the proxy must be transparent rather than opinionated. Forward Origin untouched and do not add a second copy of the header:
server {
listen 443 ssl;
server_name api.orbital-crm.net;
location /v1/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header Origin $http_origin;
proxy_set_header X-Forwarded-Proto $scheme;
# The application is authoritative for CORS. Deliberately no add_header here:
# a second Access-Control-Allow-Origin would make the response unreadable.
proxy_pass_header Access-Control-Allow-Origin;
}
}
If you would rather make Nginx authoritative and strip the application’s headers instead, use proxy_hide_header Access-Control-Allow-Origin; and emit the full set from a map block. What you cannot do is leave both layers writing.
3. Django with django-cors-headers
Add corsheaders to INSTALLED_APPS and put the middleware first in the list. The allowlist is a plain list of full origins, scheme included.
INSTALLED_APPS = [
"corsheaders",
"django.contrib.sessions",
"django.contrib.auth",
# ...
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
]
CORS_ALLOWED_ORIGINS = [
"https://app.orbital-crm.net",
"https://admin.orbital-crm.net",
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_METHODS = ["GET", "POST", "PATCH", "DELETE", "OPTIONS"]
CORS_ALLOW_HEADERS = ["authorization", "content-type", "x-request-id"]
CORS_EXPOSE_HEADERS = ["x-request-id"]
CORS_PREFLIGHT_MAX_AGE = 600
CORS_URLS_REGEX = r"^/v1/.*$"
The full walkthrough, including the APPEND_SLASH redirect that silently strips the headers, is in Configuring django-cors-headers Correctly.
4. Spring Boot with a CorsConfigurationSource bean
Spring’s CORS support exists at two levels, and only one of them runs before the security filter chain. Declaring the policy as a bean and enabling http.cors(...) installs a filter early enough to answer the preflight:
@Configuration
public class CorsConfig {
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"https://app.orbital-crm.net",
"https://admin.orbital-crm.net"));
config.setAllowedMethods(List.of("GET", "POST", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-Id"));
config.setExposedHeaders(List.of("X-Request-Id"));
config.setAllowCredentials(true);
config.setMaxAge(600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/v1/**", config);
return source;
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
}
The filter-order trap this avoids — a @CrossOrigin annotation that never runs because the security chain returns 401 first — is covered step by step in Spring Boot CORS Without Security Filter Conflicts.
5. FastAPI with Starlette’s CORSMiddleware
Starlette builds its stack by wrapping, so add_middleware calls apply in reverse: the last one added is the outermost and therefore runs first.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(GZipMiddleware, minimum_size=1024)
# Added last => outermost => sees the OPTIONS request before anything else.
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.orbital-crm.net", "https://admin.orbital-crm.net"],
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-Request-Id"],
expose_headers=["X-Request-Id"],
max_age=600,
)
6. Rails with rack-cors
Rack’s stack is explicit, and insert_before 0 puts the component ahead of everything Rails installs, including ActionDispatch::HostAuthorization, which otherwise rejects unexpected Host values before CORS runs.
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins "https://app.orbital-crm.net", "https://admin.orbital-crm.net"
resource "/v1/*",
headers: %w[Authorization Content-Type X-Request-Id],
expose: %w[X-Request-Id],
methods: %i[get post patch delete options],
credentials: true,
max_age: 600
end
end
Whichever stack you use, the response on the wire is identical, and every line in it traces back to one configuration key.
Edge Cases and Security Boundaries
Where the response leaves the stack
A CORS component that writes its headers on the way in — setting them on the response object and then delegating — protects every downstream failure. One that decorates the response on the way out loses them whenever an exception unwinds past it. And nothing the framework does can help a response that never entered the framework at all: a 413 from a proxy body-size limit or a 502 from a dead upstream ships bare, and the browser reports it as a CORS failure because that is genuinely what it sees.
Wildcard and credentials cannot coexist
Every package on this page enforces the Fetch Standard rule that Access-Control-Allow-Origin: * is incompatible with Access-Control-Allow-Credentials: true, but they enforce it differently. Spring throws an IllegalArgumentException at startup. Starlette quietly falls back to echoing the request origin, which looks like it works and is in fact unbounded reflection. django-cors-headers honours CORS_ALLOW_ALL_ORIGINS and emits the wildcard, leaving the browser to reject the response. The safe posture is identical everywhere: name your origins, and read Wildcard CORS Risks and Safe Origin Allowlisting before reaching for a shortcut.
Regex allowlists and the anchoring trap
CORS_ALLOWED_ORIGIN_REGEXES, allow_origin_regex and Spring’s setAllowedOriginPatterns all exist because tenant subdomains cannot be enumerated ahead of time. All three compare against the whole origin string, and all three are only as safe as the pattern. An unanchored orbital-crm\.net matches https://orbital-crm.net.attacker.example. Anchor with ^ and $, pin the scheme, and restrict the subdomain character class.
Development origins
Adding http://localhost:5173 to a production allowlist is the most common way a development convenience becomes a permanent hole, because any process on a developer’s machine — or on a victim’s machine, if the attacker can get them to run something on that port — then holds a credentialed grant. Keep those entries in a settings module that only the development environment loads, as described in Safely Allowing localhost Origins in Development.
Route scoping
CORS_URLS_REGEX in Django, the resource pattern in rack-cors, registerCorsConfiguration in Spring and a mounted app.use("/v1", cors(...)) in Express all narrow the policy to the routes that need it. Scoping matters for more than tidiness: a blanket policy applied to your session-login endpoint or an internal admin route grants cross-origin script access to surfaces that were never meant to have it.
Decorators are not middleware
Several stacks offer a per-handler decorator — Spring’s @CrossOrigin, Django REST Framework view mixins, and hand-rolled Express route wrappers among them. They read beautifully and they are the single most common cause of a preflight that fails while the “CORS config” looks obviously correct in code review. A decorator lives on the handler, which means it can only run once routing has resolved the request to that handler, which means every guard between the socket and the router has already had its chance to answer. Use decorators to narrow a policy that a properly mounted middleware component already applies, never as the only place the policy exists.
Exposed headers are opt-in
Browsers hand JavaScript only a small set of response headers by default — Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma. Anything else your front end reads, such as a pagination total or a request correlation id, has to be named in Access-Control-Expose-Headers. Every package on this page has a setting for it (CORS_EXPOSE_HEADERS, expose_headers, expose, exposedHeaders, setExposedHeaders), and forgetting it produces a request that succeeds while response.headers.get(...) returns null — a failure mode with no console error at all.
Proxy and CDN Interaction
Middleware configuration only holds if the path between the browser and the framework leaves it alone. Three interactions account for nearly all production surprises.
Header duplication. When both a proxy and the application emit Access-Control-Allow-Origin, the response carries two copies and the browser treats the value as malformed. This is not additive and there is no precedence rule to learn. Decide which layer owns the policy and silence the other; the diagnosis procedure is in Fixing Duplicate Access-Control-Allow-Origin Headers.
Cache partitioning. As soon as your middleware reflects a validated origin rather than a constant, the response body-plus-headers pair differs per caller, so every shared cache in the path must key on Origin. Most packages add Vary: Origin for you when the allowlist is dynamic — django-cors-headers and the Express cors package both do — but a CDN that consolidates or drops Vary undoes that work. Confirm the header survives to the client.
Preflight interception. Some gateways answer OPTIONS themselves before the request ever reaches your service, using a policy configured in the gateway console. When that happens your framework settings are dead code and every change you make to them has no effect, which is a uniquely frustrating debugging session. Send an OPTIONS request with a deliberately wrong Access-Control-Request-Method and see whether the response still looks plausible; if it does, something upstream is answering.
Timeouts and buffering. A proxy that buffers a large response and then times out returns its own gateway error, which — per the exit diagram above — carries no CORS headers. Users report “CORS errors” that are really upstream timeouts. Check the status code in the Network panel before believing the console message.
DevTools and curl Verification Checklist
Run this after every change to middleware order or allowlist settings, in each environment separately.
curl -sS -i -X OPTIONS https://api.orbital-crm.net/v1/orders \ -H 'Origin: https://app.orbital-crm.net' \ -H 'Access-Control-Request-Method: PATCH' \ -H 'Access-Control-Request-Headers: authorization, content-type' \ | head -n 20curl -sS -o /dev/null -D - -X OPTIONS https://api.orbital-crm.net/v1/orders \ -H 'Origin: https://app.orbital-crm.net.attacker.example' \ -H 'Access-Control-Request-Method: PATCH' \ | grep -i 'access-control-allow-origin'curl -sS -o /dev/null -D - https://api.orbital-crm.net/v1/orders \ -H 'Origin: https://app.orbital-crm.net' \ | grep -ci '^access-control-allow-origin:'
Common Mistakes
| Mistake | Technical impact | Fix |
|---|---|---|
| Registering the CORS component after an authentication guard | The guard answers the credential-free preflight with 401; the browser reports a missing Access-Control-Allow-Origin |
Move the registration to the earliest position on the request path for that stack |
Adding CORSMiddleware first in a FastAPI app |
Starlette wraps outward, so the first addition ends up innermost and other middleware sees the preflight first | Add it last, or use Middleware(...) in the FastAPI(middleware=[...]) constructor list |
| Configuring CORS in both the proxy and the framework | Two Access-Control-Allow-Origin headers; browsers reject the response outright |
Pick one authoritative layer; use proxy_hide_header or delete the framework config |
| Enabling credentials alongside a wildcard origin | Spring fails at startup, Starlette silently reflects every origin, browsers block the response | Enumerate origins explicitly; never pair * with credentials |
| Leaving an unanchored regex in the allowlist | https://your-domain.example.attacker.test matches and gains a credentialed grant |
Anchor with ^ and $, pin the scheme, restrict the subdomain character class |
| Applying one blanket policy to every route | Login, admin and internal endpoints inherit a cross-origin grant they never needed | Scope with CORS_URLS_REGEX, resource, registerCorsConfiguration or a mounted router |
| Assuming a passing preflight means the request will succeed | The actual response needs its own Access-Control-Allow-Origin; a proxy-generated error will not have one |
Verify headers on both the OPTIONS entry and the real request |
FAQ
Why does registration order matter so much for CORS middleware?
Because a preflight OPTIONS request carries no cookies, no Authorization header and no request body, almost every other middleware in the stack has a reason to reject it. An authentication guard sees an anonymous request, a slash normaliser sees a path it wants to redirect, and a body parser sees a content type it does not recognise. Whichever component ends the response first decides what headers it carries, so the CORS component has to run before all of them or its configuration is simply never consulted.
Do CORS middleware packages answer the OPTIONS request themselves?
Most do, and that is the behaviour you want. The cors package for Express, Starlette’s CORSMiddleware used by FastAPI, django-cors-headers and rack-cors all detect a request whose method is OPTIONS and whose Access-Control-Request-Method header is present, write the response header set and end the exchange without invoking your route. Spring is the exception in spirit: its CORS support is a filter that short-circuits the preflight before the dispatcher runs, but only when it is wired into the security filter chain rather than declared on a controller. The design considerations for that short-circuit are covered in OPTIONS Endpoint Design for CORS Preflights.
Should I configure CORS in the framework or at the reverse proxy?
Pick exactly one layer and make it authoritative. The framework knows which routes exist and which of them need credentials, so it is the better place for per-route policy. The proxy is faster and survives an application crash, so it is the better place for a single blanket policy on a static asset host. What you must never do is configure both, because each layer appends its own Access-Control-Allow-Origin and the browser rejects a response that carries two.
Why do my error responses lose their CORS headers?
It depends on whether the middleware writes its headers on the way in or on the way out. Packages that call setHeader before delegating to the next component leave the headers staged on the response object, so a later 401, 404 or 500 still carries them. Packages or hand-rolled wrappers that decorate the response after the handler returns lose the headers whenever an exception unwinds past them, and anything that terminates above the middleware entirely, such as a proxy body-size limit, was never going to have them.