Why your API still redirects to login in .NET 10

These two endpoints sit on the same app, behind the same authorization policy, and serialize the same object to the same JSON.
app.MapGet("/min/typed-results", () => TypedResults.Ok(thing)).RequireAuthorization("staff");
app.MapGet("/min/results-ok", () => Results.Ok(thing)).RequireAuthorization("staff");
An anonymous GET to the first one comes back 401. An anonymous GET to the second comes back 302, pointing at /Account/Login?ReturnUrl=%2Fmin%2Fresults-ok. I hit this while moving a service to .NET 10 and spent an embarrassing amount of time looking at the authorization policy, which was never the problem.
What changed
In .NET 10 the cookie authentication handler stopped sending unauthenticated and unauthorized requests to the login and access-denied pages for endpoints it treats as API endpoints, and answers 401 and 403 instead. Microsoft's breaking-change notice puts it plainly: the redirect "doesn't usually make sense for API endpoints, which typically rely on 401 and 403 status codes rather than HTML redirects to communicate auth failures". The change landed in Preview 7 and shipped in 10.0.
That is a good change. Before it, an SPA calling a protected endpoint after its cookie expired got a 302, the browser followed it without telling anyone, and the app received a 200 full of login HTML where it expected JSON. Debugging that the first time is a rite of passage.
The word doing the work there is treats. The decision comes from endpoint metadata, which is not where I would have looked for it.
The mark
When the handler is about to redirect, it looks for a piece of metadata on the matched endpoint. As of ASP.NET Core 10.0.5 the two interfaces are Microsoft.AspNetCore.Http.Metadata.IDisableCookieRedirectMetadata and Microsoft.AspNetCore.Http.Metadata.IAllowCookieRedirectMetadata, and the second one wins whatever order they appear in.
Check the names before you go looking for them. The breaking-change page names IApiEndpointMetadata under "Affected APIs", and that interface is not in the 10.0.5 reference assembly; its own API link is a 404. It was the preview spelling. The demo asserts the assembly half of that, so if a later patch brings the name back the test goes red rather than this paragraph going quietly wrong; the two documentation facts are as of the notice's current revision.
ASP.NET Core adds the disable mark for you on some shapes. The notice lists [ApiController] endpoints, minimal APIs that read or write JSON, endpoints using TypedResults, and SignalR. All four hold up when you measure them, and the first one is more literal than it sounds: ApiControllerAttribute implements IDisableCookieRedirectMetadata itself, so the attribute already on your controller is the metadata the handler finds.
TypedResults.Ok(thing)
Results.Ok(thing)
The table
Nine endpoint shapes, one per row, all behind the same staff policy so that an anonymous request produces a challenge and a signed-in request without the claim produces a forbid. Measured on .NET 10.0.5 through an in-memory test server with automatic redirects turned off:
endpoint shape anon forbidden
controller with [ApiController] 401 403
controller without [ApiController], returning JSON 302 302
minimal API returning TypedResults.Ok(obj) 401 403
minimal API returning Results.Ok(obj) as IResult 302 302
minimal API returning a plain object 401 403
minimal API returning a string 302 302
minimal API reading a JSON body, returning a string 401 403
Razor Page 302 302
SignalR hub endpoint 401 403
On .NET 9.0.14, with the same source file, every one of those rows is 302 and 302. Two of them are the ones I would not have guessed. Results.Ok(obj) is declared as IResult, which tells the request-delegate factory nothing about what will come out, so no mark gets added and the endpoint keeps redirecting. TypedResults.Ok(obj) is declared as Ok<Thing>, which implements IEndpointMetadataProvider and contributes metadata at build time. One word of difference in the source, two different answers to the same request.
The other is the string. A minimal API returning a string writes text/plain, so it is not "an endpoint that writes JSON" and it still redirects, even though nobody would call it a page. Same for a controller that returns Ok(new Thing(...)) without [ApiController] on it: same base class, same JSON body, no mark.
The 401 still carries a Location
This one cost me a while, so it is worth a sentence of its own. The 401 and the 403 still have the Location header they would have redirected to. All nine rows above return Location: http://localhost/Account/Login?ReturnUrl=… on a challenge and .../Account/Denied?ReturnUrl=… on a forbid, whether the status line says 302 or 401. If your check is "did I get a Location header", it will tell you nothing. Read the status code.
The XHR exception is still there
Before .NET 10, one thing already avoided the redirect: a request carrying X-Requested-With: XMLHttpRequest. That exception has not gone anywhere. On 10.0.5 the header turns every shape in the table into 401 and 403, including the shapes that otherwise redirect, and including an endpoint carrying an explicit allow mark — so the header is checked before the metadata is. The value in the query string counts too, which surprised me:
GET /min/string -> 302
GET /min/string with X-Requested-With: XMLHttpRequest -> 401
GET /min/string?X-Requested-With=XMLHttpRequest -> 401
Worth knowing if you are hoping the header will carry a half-migrated front end through this. It will, and it is also not something Angular does for you. I checked the published @angular/common 22.1.6 package and the string X-Requested-With does not appear anywhere in it. jQuery's ajax set that header, which is where most of the habit came from.
Marking an endpoint yourself
For minimal APIs and anything else with a convention builder, there are two extension methods on CookieRedirectEndpointConventionBuilderExtensions:
app.MapGet("/min/string-disabled", () => "widget")
.RequireAuthorization("staff")
.DisableCookieRedirect(); // -> 401 / 403
app.MapGet("/min/typed-results-allowed", () => TypedResults.Ok(thing))
.RequireAuthorization("staff")
.AllowCookieRedirect(); // -> 302 / 302, even though the
// disable mark is also present
For MVC, the allow side ships an attribute, [AllowCookieRedirect] in Microsoft.AspNetCore.Http. The disable side does not, and the concrete class behind DisableCookieRedirect() is not public in 10.0.5, so a controller that wants the mark declares it:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)]
public sealed class DisableCookieRedirectAttribute : Attribute, IDisableCookieRedirectMetadata;
Attributes on the controller and the action become endpoint metadata, which is where the handler looks for the mark, so that one line is enough. The demo puts it on a controller with no [ApiController] and measures 401 and 403.
Checking your own app
Guessing which shape you have is how this eats an afternoon. Every routed endpoint carries its metadata, so ask:
app.MapGet("/__endpoints", (EndpointDataSource endpoints) =>
endpoints.Endpoints.OfType<RouteEndpoint>().Select(e => new
{
route = e.RoutePattern.RawText,
disables = e.Metadata.GetMetadata<IDisableCookieRedirectMetadata>() is not null,
allows = e.Metadata.GetMetadata<IAllowCookieRedirectMetadata>() is not null,
markedBy = e.Metadata.GetMetadata<IDisableCookieRedirectMetadata>()?.GetType().FullName,
}));
Printing markedBy is the part I would keep. On the demo app it reports Microsoft.AspNetCore.Mvc.ApiControllerAttribute for the attributed controller and an internal DisableCookieRedirectMetadata for the minimal APIs, which answers "where did this come from" without a debugger. Do not assert on those concrete type names; they are internal and free to change. The interface is the contract.
What the browser does with the 302
The demo is server-side, so treat the next paragraph as standard browser behaviour rather than something I measured.
Both fetch and XMLHttpRequest follow redirects for you. The Fetch Standard defines a request's redirect mode with "follow" as the default, and fetch exposes response.redirected after the fact rather than handing you the 302. So a front end that gets the redirect never sees a 302 at all. It sees a 200 whose body is the login page, and whatever it does next depends entirely on how badly the JSON parse fails. In Angular 22.1.6 HttpClient goes through FetchBackend by default. (withFetch() is marked deprecated in the published types now, with the note "withFetch is not required anymore. FetchBackend is the default HttpBackend", and withXhr() is how you go back to XMLHttpRequest.) Neither backend changes the redirect behaviour. Which is the real argument for the .NET 10 default: an HTTP interceptor can do something sensible with a 401, and by the time a redirect has been followed there is no status code left for it to act on.
If you need the old behaviour back
The documented route is to override the two events, which replaces the handler's decision entirely:
builder.Services.AddAuthentication()
.AddCookie(options =>
{
options.Events.OnRedirectToLogin = context =>
{
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
});
Every shape in the table goes back to 302, which is what you asked for, and so does every request carrying X-Requested-With, which you probably did not. Both checks live in the default OnRedirectToLogin and OnRedirectToAccessDenied delegates, so replacing the delegates removes both. The notice publishes a longer version that keeps the XHR check, and if you take this route I would take that one; the demo asserts what the short version costs you.
I would rather mark the handful of endpoints that need it and leave the default alone. .DisableCookieRedirect() on the odd string-returning endpoint is one line in the place a reader is already looking, and it keeps saying the same thing after someone rewrites what the handler returns.
The versions this holds for
Measured on ASP.NET Core 10.0.5 and 9.0.14 out of the shared framework, both target frameworks built with .NET SDK 10.0.201, on Windows 11, through Microsoft.AspNetCore.Mvc.Testing with AllowAutoRedirect off. I have not run it on Linux, and I have not tested .NET 8 or a custom IAuthenticationHandler that wraps the cookie one. The table is a description of these two runtimes, not of the API's history.
The demo is at github.com/gdhami-net/cookie-api-redirect-demo. ./check.sh or ./check.ps1 runs 72 tests on net9.0 and 94 on net10.0; the expectations are compiled per target framework, so the same source file is the before table and the after table. --filter "Print_the_table|Print_what_marks" prints both tables from a live run if you would rather not take mine.