August 6, 2026
A Junior Asked Why the Login Redirect Doesn’t Fail CORS. It’s a Better Question Than It Sounds.
The identity provider lives on another domain. CORS blocks cross-origin requests. So why does the redirect back to your app sail through…

By Krati Varshney
12 min read
The identity provider lives on another domain. CORS blocks cross-origin requests. So why does the redirect back to your app sail through untouched?
Last week one of the juniors in my C# training batch pinged me with a question:
"Mam, our app redirects to the identity provider, where the user logs in, and then the IDP redirects back to our app. That's a different domain that sends the browser to ours — cross-origin. Why is that not giving me a CORS error?"
I like this question a lot. He knew the standard mental model — CORS blocks cross-origin traffic — saw that an OpenID Connect login flow is just cross-origin traffic, and noticed the apparent contradiction. Most developers never notice this, because most of us carry our CORS knowledge as a pile of Stack Overflow fixes, not as a model.
I could have replied to him in one sentence. It wouldn't have done any good, because the one-line answer only makes sense when your model of CORS is correct — and his, like most people's, was wrong in one load-bearing spot. So I made him sit through the explanation I'm about to make you sit through: what CORS actually is, built from zero. About halfway down the answer to his question drops out of it naturally. And then we'll flip the question around, because the same model also explains every CORS error you've ever seen in a login flow — there are exactly four, and each has a concrete ASP.NET Core fix.
What CORS actually does
Most developers think CORS is a security system that blocks requests. Both halves of that are wrong, and the wrongness is exactly why these bugs are hard to debug.
Start with the Same-Origin Policy. Browsers enforce a default rule: JavaScript running on one origin cannot read responses from a different origin. An origin is the exact combination of scheme, host, and port. These are four different origins:
https://app.example.com
https://api.example.com
http://app.example.com
https://app.example.com:8443https://app.example.com
https://api.example.com
http://app.example.com
https://app.example.com:8443Different subdomain? Different origin. HTTP vs HTTPS? Different origin. Port 443 vs 8443? Different origin. This policy exists so that a random tab you have open can't silently read your bank's API responses using the session cookie your browser would happily attach.
CORS is not the thing that blocks. CORS is the thing that relaxes the blocking. Cross-Origin Resource Sharing is a protocol that lets a server say "I'm fine with JavaScript from that specific origin reading my responses." No CORS headers means the default applies — locked down — which is why it feels like CORS is blocking you. It isn't. The Same-Origin Policy is blocking you. CORS is the permission slip that was never issued.
Second misconception: developers assume the server rejected the request. Usually it didn't. In most CORS failures, the request reached your server, your server processed it, and your server responded. The browser received that response, checked it for an Access-Control-Allow-Origin header matching the page's origin, didn't find one, and refused to hand the body to your JavaScript. The data made the whole round trip and got confiscated at the last step.
This is also why the same endpoint works perfectly from Postman, curl, or your integration tests. CORS is enforced by browsers, on behalf of users, against pages. There is no browser in Postman, so there is no CORS in Postman. If your API "has a CORS bug" in Postman, you have a different bug.
Preflights, quickly
For anything beyond simple GETs and form posts, the browser sends an advance OPTIONS request — the preflight — asking the server whether the real request is allowed:
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-typeOPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-typeThe server is expected to answer with what it permits:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600A Content-Type: application/json or an Authorization header is enough to trigger a preflight — which is nearly every real API call you make. Remember that the preflight itself carries no cookies and no auth headers. That detail will matter in the checklist section, because a globally applied [Authorize] filter that 401s an anonymous OPTIONS request produces — you guessed it — a console error that says CORS.
One more spec rule worth memorizing: Access-Control-Allow-Origin: * is forbidden in combination with credentials. If your request sends cookies, the server must echo an exact origin. ASP.NET Core enforces this at startup — combine AllowAnyOrigin() with AllowCredentials() in one policy and you get a runtime exception, which is the framework saving you from shipping a config the browser would reject anyway.
Notice the thread running through everything above. The Same-Origin Policy restricts what JavaScript can read. The preflight announces what JavaScript is about to send. The blocked response is confiscated from JavaScript. That's not a coincidence — it's the whole design, and it's the piece my junior's mental model was missing.
The answer: CORS only watches your JavaScript
Here is the rule almost nobody states out loud:
CORS applies to programmatic cross-origin requests — things your code initiates with fetch or XMLHttpRequest.
CORS does not apply to top-level navigations. When the browser itself moves from one page to another — you click a link, submit a classic form, type a URL, or a server responds with a 302 and the browser follows it in the address bar — no CORS check happens. At all. And it makes sense once you know what the Same-Origin Policy is protecting: it stops a page's scripts from reading another origin's data. A navigation hands the whole tab over to the destination. There's no script left behind to steal anything, so there's nothing for CORS to police.
Now walk the login flow my junior asked about:
- User opens
https://yourapp.com. Unauthenticated. Your app responds with a 302 tohttps://idp.example.com/connect/authorize?.... The browser navigates there. Navigation — no CORS. - User logs in on the IDP's page. Same-origin form post on the IDP's own domain. No CORS.
- IDP responds with a 302 back to
https://yourapp.com/signin-oidc?code=.... The browser navigates back. Navigation — no CORS.
Every hop is the browser itself moving between pages. The redirect back — the leg he was asking about — gets no more CORS scrutiny than typing the URL into the address bar would. The entire login dance completes without a single CORS check firing. That's the answer.
But it raises the obvious follow-up, and this is where his question earns its keep: if the redirects are exempt, why has every .NET developer reading this seen a CORS error in a login flow? Because something in those broken flows was not a navigation. Somewhere, JavaScript was making a cross-origin request — usually without the developer realizing it. So when your console shows a CORS error with your IDP's domain in it, the question is never "why is the IDP blocking the redirect." The question is: why is my JavaScript talking to the IDP at all?
Four answers to that question cover every case I've ever debugged. Here they are, worst first.
Culprit #1: Your API answered an AJAX call with a redirect
This is the big one. In my experience it explains the majority of "the IDP throws CORS" reports I've been pulled into over the years.
Picture the setup. A React or Angular frontend, an ASP.NET Core backend, cookie-based auth with OpenID Connect — a completely standard configuration:
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://idp.example.com";
options.ClientId = "webapp";
options.ResponseType = "code";
});builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://idp.example.com";
options.ClientId = "webapp";
options.ResponseType = "code";
});Everything works. User logs in, uses the app, goes to lunch. The auth cookie expires. They come back and click something, and the SPA fires:
const response = await fetch('/api/orders', { credentials: 'include' });const response = await fetch('/api/orders', { credentials: 'include' });The request hits [Authorize], the cookie is dead, and the cookie authentication handler does what it was built to do: it issues a 302 redirect to the login path, which challenges the OIDC handler, which redirects to [https://idp.example.com/connect/authorize?...](https://idp.example.com/connect/authorize?....).
That behavior is perfect for a browser navigation. It is a disaster for a fetch call, because fetch silently follows redirects by default. Your JavaScript — not the browser's navigation machinery, your script — is now making a cross-origin request to the IDP's authorize endpoint.
The authorize endpoint was designed for navigations. It does not send Access-Control-Allow-Origin headers, and it shouldn't. So the browser blocks the response, and your console prints something like:
Access to fetch at 'https://idp.example.com/connect/authorize?client_id=...'
(redirected from 'https://yourapp.com/api/orders') has been blocked by
CORS policy: No 'Access-Control-Allow-Origin' header is present on the
requested resource.Access to fetch at 'https://idp.example.com/connect/authorize?client_id=...'
(redirected from 'https://yourapp.com/api/orders') has been blocked by
CORS policy: No 'Access-Control-Allow-Origin' header is present on the
requested resource.Read that error the way a stressed developer reads it at 6 PM: IDP domain, blocked, CORS. Conclusion: "the IDP redirect throws CORS." The redirected from clause — the part that names the real offender, your own API — gets skimmed right past.
The fix is a rule I now give every team I work with: APIs consumed by JavaScript return 401, never 302. Redirects are for pages. Status codes are for scripts. In ASP.NET Core the cookie handler exposes exactly the right hook:
.AddCookie(options =>
{
options.Events.OnRedirectToLogin = context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
}).AddCookie(options =>
{
options.Events.OnRedirectToLogin = context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
})And on the frontend, the 401 becomes a navigation — which, as we established, CORS has no jurisdiction over:
const response = await fetch('/api/orders', { credentials: 'include' });
if (response.status === 401) {
window.location.href =
'/account/login?returnUrl=' +
encodeURIComponent(window.location.pathname);
return;
}const response = await fetch('/api/orders', { credentials: 'include' });
if (response.status === 401) {
window.location.href =
'/account/login?returnUrl=' +
encodeURIComponent(window.location.pathname);
return;
}Same IDP. Same login flow. Same redirect out and back. Zero CORS errors — because the cross-origin hop now happens in the address bar instead of inside fetch.
The redirected from fragment is the entire story, sitting right there in the error text, and nobody reads it. The IDP's domain in the first line grabs all the attention while the actual offender — your own API — hides in a parenthetical. Every time I've debugged this with a teammate, pointing at those two words has been the whole fix.
Culprit #2: The token endpoint is a different door
The second pattern shows up in SPAs doing authorization code flow with PKCE — no backend session, the SPA itself exchanges the authorization code for tokens.
Walk the flow again, carefully this time:
- Redirect out to the IDP. Navigation.
- Redirect back with
?code=.... Navigation. - The SPA reads the code and POSTs it to
https://idp.example.com/connect/token. This is a fetch. CORS applies.
Step 3 is a legitimate, by-design, cross-origin JavaScript request — the one leg of the login dance where the IDP genuinely must send CORS headers for your origin. If it isn't configured to, the login "fails after redirect back," which is precisely the phrasing that ends up in the bug report. The redirect worked. The XHR right after it didn't.
Every identity provider configures this separately from the redirect URI, and the split is where people get burned:
ProviderRedirect configCORS configDuende IdentityServerRedirectUrisAllowedCorsOriginsAuth0Allowed Callback URLsAllowed Web OriginsMicrosoft Entra IDRedirect URI ("Web" platform)Redirect URI under the "Single-page application" platform
In Duende IdentityServer, a correct SPA client carries both:
new Client
{
ClientId = "spa-client",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RequireClientSecret = false,
// Protects the redirect back - a navigation
RedirectUris = { "https://app.example.com/callback" },
// Protects the token exchange - a fetch. Different mechanism entirely.
AllowedCorsOrigins = { "https://app.example.com" },
AllowedScopes = { "openid", "profile", "api" }
}new Client
{
ClientId = "spa-client",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RequireClientSecret = false,
// Protects the redirect back - a navigation
RedirectUris = { "https://app.example.com/callback" },
// Protects the token exchange - a fetch. Different mechanism entirely.
AllowedCorsOrigins = { "https://app.example.com" },
AllowedScopes = { "openid", "profile", "api" }
}RedirectUris and AllowedCorsOrigins guard two different requests. Configure only the first and your login flow gets all the way to the final step before dying with a CORS error against the token endpoint.
The Entra ID version of this mistake deserves its own sentence, because I've watched multiple .NET teams lose an afternoon to it: registering a SPA's redirect URI under the "Web" platform in the app registration produces a working redirect and a CORS-blocked token request. Moving the same URI to the "Single-page application" platform is the entire fix — that platform type is what tells Entra to answer token requests with CORS headers.
Culprit #3: The iframe that looks like CORS
Older SPA setups renew tokens silently: a hidden iframe loads the authorize endpoint with prompt=none, riding the IDP's session cookie to mint a fresh token without bothering the user.
Two things kill this, and both produce console errors that get lumped in with CORS:
Frame blocking. If the IDP sends X-Frame-Options: DENY or a CSP with frame-ancestors 'none', the iframe refuses to load. The error mentions your IDP's domain and the word "blocked." It is not a CORS error — Refused to display ... in a frame is a framing policy, enforced by a different header, fixed in a different place. Adding CORS configuration does nothing.
Third-party cookie blocking. The iframe trick depends on the browser sending the IDP's session cookie from inside a frame on your origin — which makes it a third-party cookie. Safari's ITP has blocked those for years. Chrome spent several years announcing deprecation before reversing course in 2024 and keeping third-party cookies, but the cross-browser reality is unchanged: you cannot ship a login flow that assumes third-party cookies work. Silent renew via iframe is dead as a portable strategy. Refresh tokens with rotation — or better, the BFF pattern we'll get to — replaced it.
The diagnostic tell: culprit 3 errors mention frames, X-Frame-Options, CSP, or simply manifest as prompt=none returning login_required when a session clearly exists. Different disease, same waiting room.
Culprit #4: "Correlation failed" — and someone adds a CORS policy
The last one doesn't even produce a CORS error, but I've seen the misdiagnosis often enough to include it.
When the ASP.NET Core OIDC middleware redirects out to the IDP, it first drops temporary cookies on your domain — .AspNetCore.Correlation.* and a nonce cookie — so it can verify, when the browser comes back, that the response matches a request it initiated. CSRF protection for the login flow itself.
The redirect back from the IDP is a cross-site request from the browser's point of view. If it arrives as a POST — response_mode=form_post is the classic trigger — modern browsers will only attach those temporary cookies if they were set with SameSite=None; Secure. If they weren't, the cookies stay home, the middleware finds nothing to correlate against, and you get:
Exception: Correlation failed.Exception: Correlation failed.Or its more theatrical cousin, the infinite login loop: challenge, IDP, redirect back, correlation fails, unauthenticated, challenge, IDP…
The developer's mental model goes: cross-site request → failing → cross-origin thing → CORS. A CORS policy gets added to the API. Nothing changes, because cookies-on-navigation is SameSite's territory, not CORS's. Two different browser mechanisms that both happen to contain the word "cross."
The actual fix:
.AddOpenIdConnect(options =>
{
options.NonceCookie.SameSite = SameSiteMode.None;
options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always;
options.CorrelationCookie.SameSite = SameSiteMode.None;
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always;
}).AddOpenIdConnect(options =>
{
options.NonceCookie.SameSite = SameSiteMode.None;
options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always;
options.CorrelationCookie.SameSite = SameSiteMode.None;
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always;
})SameSite=None is only honored over HTTPS — Secure is mandatory, and Chrome will discard the cookie on plain http:// even on localhost in some configurations. If your login flow works in production and loops forever on a teammate's HTTP dev setup, this paragraph is why.
The ASP.NET Core checklist
Everything above, compressed into the config I actually review when someone reports auth CORS problems:
const string SpaCors = "SpaPolicy";
builder.Services.AddCors(options =>
{
options.AddPolicy(SpaCors, policy =>
{
policy.WithOrigins("https://app.example.com") // exact scheme+host+port, no trailing slash
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
var app = builder.Build();
app.UseRouting();
app.UseCors(SpaCors); // after UseRouting, before auth
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();const string SpaCors = "SpaPolicy";
builder.Services.AddCors(options =>
{
options.AddPolicy(SpaCors, policy =>
{
policy.WithOrigins("https://app.example.com") // exact scheme+host+port, no trailing slash
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
var app = builder.Build();
app.UseRouting();
app.UseCors(SpaCors); // after UseRouting, before auth
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();Five things I check, in order:
Middleware order. UseCors sits after UseRouting and before UseAuthentication/UseAuthorization. Put it after authorization and the anonymous preflight OPTIONS request can get rejected before the CORS middleware ever runs — surfacing in the browser as a CORS failure with a perfectly correct CORS policy sitting in your code.
The origin string. WithOrigins("https://app.example.com/") — with a trailing slash — matches nothing, ever, because the Origin header the browser sends never has one. I have personally lost an hour to that slash. Exact scheme, exact host, exact port, nothing after.
Credentials vs wildcard. AllowCredentials() requires explicit origins. The framework throws if you pair it with AllowAnyOrigin(), and the spec is on the framework's side.
Preflights and auth. If you've applied [Authorize] globally via a fallback policy, confirm OPTIONS requests aren't caught in it. The preflight arrives with no credentials by design.
Both CORS surfaces. Your API's CORS policy covers requests to your API. The IDP's AllowedCorsOrigins (or provider equivalent) covers the token endpoint. They are separate configurations protecting separate requests, and a working login needs whichever ones your architecture actually uses.
And the architectural cheat code: the Backend-for-Frontend pattern. Put a thin server-side host on the same origin as your SPA, keep the tokens in it, and proxy API calls through it — with YARP, this is a few lines. The browser only ever talks to its own origin. No cross-origin fetches means no CORS configuration, no token endpoint exposure, no tokens in browser storage. Roughly ninety percent of this article structurally cannot happen to a BFF. It's where the ecosystem has been heading for years, and having debugged everything above the hard way, I understand why.
How I actually debug these now
Skip the console. The console shows you the symptom, pre-blamed. Open the Network tab, find the red request, and answer three questions:
Was it a navigation or a fetch? Check the request's initiator and type. A document navigation cannot fail CORS — if the failing request is a navigation, whatever is wrong isn't CORS, full stop. If it's fetch/xhr, keep going.
Is there a redirect chain? A 302 sitting above the failed request means your JavaScript got bounced somewhere it was never meant to go — that's culprit 1, and the fix belongs on the endpoint that issued the redirect, not on the domain the error names.
Which request actually failed — preflight or real? A failing OPTIONS points at middleware order, auth-on-preflight, or a policy mismatch. A failing POST after a clean preflight points at response headers on the real request.
Three questions, ninety seconds, and you'll know which of the four culprits you're holding.
As for my junior — I drew the flow on a whiteboard and marked every leg with one of two labels: navigation or fetch. Navigations sail. Fetches face the CORS gate. Once he saw that the login dance is navigations all the way through, and that every real-world failure comes from a fetch sneaking into the picture, he asked the natural follow-up: "So every CORS error anyone has ever seen in a login flow was one of these four?" Every single one I've debugged, yes.
His original question was better than he knew. "Why doesn't this fail?" forced an answer about what CORS fundamentally is — a browser policy on script-initiated requests, nothing more — instead of another copy-pasted middleware fix. If your mental model can explain why the redirect succeeds, you'll never again be confused about why the token call fails.
So the next time a login flow shows you a CORS error wearing your identity provider's domain, you already know it wasn't the redirect. It structurally can't be. Ask the only question that matters: why is my JavaScript talking to the IDP at all? The answer is the bug.