What Changed in Authentication and Identity from ASP.NET Core 6 to 11
The most important authentication changes from ASP.NET Core 6 to 10 don't break the build; they silently change behavior. We run the same code on .NET 6, 8 and 10 to show a single scheme becoming the default, SecurityToken turning into JsonWebToken, cookies returning 401 instead of 302 for APIs and the new metrics; then cover PAR in .NET 9, .NET 11's TimeProvider, passkey names and DBSC, and a migration checklist.
Version note: The code in this post was built and run from the same source on the .NET 6.0.36, 8.0.25 and 10.0.13 runtimes; the .NET 11 items come from the release notes of the RC (Release Candidate) period in November 2026, and no code was run on .NET 11.
Guess First
Bookstore has an internal service written in 2022 with .NET 6: it is protected by JWT (JSON Web Token) Bearer, plus a small admin panel with cookies. The team moves it to .NET 10; the code compiles and the tests pass. Which of the following changes silently? Code in OnTokenValidated that casts context.SecurityToken to JwtSecurityToken. The response a signed-out user gets from the panel. And: did a project that forgot to pass a scheme name to AddAuthentication() work on .NET 6?
This is the last post of the series. From 01 to 17 we built authentication and authorization piece by piece; here we look back, list what changed in this area from ASP.NET Core 6 to 11, and show the most important behavior differences by running the same code on three runtimes. The ASP.NET Core Web API (Application Programming Interface) series' posts on what's new in .NET 7, 8, 9 and 10 drew the general picture of each release; here we only look at the identity and authorization side.
The Setup: Same Code, Three Runtimes
A single console app brings up two small web apps in the same process and sends requests to itself. The project targets net8.0 and net10.0; for .NET 6 I built the same files in a temporary container from the mcr.microsoft.com/dotnet/sdk:6.0 image. I kept the code compilable with C# 10, so no collection expressions or primary constructors:
// 1) Only JwtBearer is registered; without DEFAULT_SCHEME no scheme name is passed to AddAuthentication()
string? defaultScheme = Environment.GetEnvironmentVariable("DEFAULT_SCHEME");
WebApplicationBuilder jwtBuilder = WebApplication.CreateBuilder();
jwtBuilder.Logging.ClearProviders();
jwtBuilder.WebHost.UseUrls("http://127.0.0.1:19217");
AuthenticationBuilder auth = defaultScheme is null
? jwtBuilder.Services.AddAuthentication()
: jwtBuilder.Services.AddAuthentication(defaultScheme);
auth.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "bookstore-lab",
ValidAudience = "bookstore-api",
IssuerSigningKey = key
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = context =>
{
context.HttpContext.Items["tokenType"] = context.SecurityToken.GetType().Name;
return Task.CompletedTask;
}
};
});
// 2) Cookie authentication, a minimal API endpoint returning JSON
cookieBuilder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
cookieApp.MapGet("/orders", () => new[] { new { Id = 1001, Total = 320m } }).RequireAuthorization();
A MeterListener also collects the instruments published on the Microsoft.AspNetCore.Authentication and Microsoft.AspNetCore.Authorization meters. The signing key is generated randomly on every run. In the project file the package version is chosen per target framework:
<ItemGroup Condition="'$(TargetFramework)' == 'net6.0'">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.36" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.33" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />
</ItemGroup>
The output of the three runtimes:
.NET 6.0.36
jwt, no token : 500 InvalidOperationException: No authenticationScheme was specified, and there was no DefaultChallengeScheme found. The default schemes can be set using either AddAuthentication(string defaultScheme) or AddAuthentication(Action<AuthenticationOptions> configureOptions).
jwt, with token: 500 InvalidOperationException: No authenticationScheme was specified, and there was no DefaultChallengeScheme found. The default schemes can be set using either AddAuthentication(string defaultScheme) or AddAuthentication(Action<AuthenticationOptions> configureOptions).
cookie, no auth: 302 -> http://127.0.0.1:19218/Account/Login?ReturnUrl=%2Forders
auth metrics: (none)
.NET 6.0.36 (DEFAULT_SCHEME=Bearer)
jwt, no token : 401
jwt, with token: 200 SecurityToken = JwtSecurityToken
cookie, no auth: 302 -> http://127.0.0.1:19218/Account/Login?ReturnUrl=%2Forders
auth metrics: (none)
.NET 8.0.25
jwt, no token : 401
jwt, with token: 200 SecurityToken = JsonWebToken
cookie, no auth: 302 -> http://127.0.0.1:19218/Account/Login?ReturnUrl=%2Forders
auth metrics: (none)
.NET 10.0.13
jwt, no token : 401
jwt, with token: 200 SecurityToken = JsonWebToken
cookie, no auth: 401 -> http://127.0.0.1:19218/Account/Login?ReturnUrl=%2Forders
auth metrics: aspnetcore.authentication.authenticate.duration, aspnetcore.authentication.challenges, aspnetcore.authentication.forbids, aspnetcore.authentication.sign_ins, aspnetcore.authentication.sign_outs, aspnetcore.authorization.attempts
Four groups of lines, four release changes. In order below.
.NET 7: A Single Scheme Becomes the Default, user-jwts Arrives
The 500 error in the .NET 6 output answers the last Guess First question: on .NET 6, even with a single scheme registered, if you don't tell AddAuthentication() the default, every protected endpoint throws. The .NET 7 release notes call it "Authentication uses single scheme as DefaultScheme": if exactly one scheme is registered, it automatically becomes the default. In the .NET 8 and 10 output the same code returns 401. The practical effect: in .NET 7+ projects, code that works without this line loses the automatic behavior the day a second scheme is added (say, a cookie next to JwtBearer) and produces the .NET 6 error. With more than one scheme, always set the defaults explicitly; the policy scheme discussion in post 01 is for that.
.NET 7's other two contributions are developer ergonomics. The dotnet user-jwts tool issues signed JWTs for development and stores the signing key in user-secrets (in this series we generated keys in the work folder instead, so as not to write to the user profile). AddAuthorizationBuilder() made policy definitions chainable; the .AddPolicy("books.read", ...) chain in post 13 was written with it, and the API documentation shows the method has existed since ASP.NET Core 7.0.
.NET 8: Identity API Endpoints, JsonWebTokenHandler and IAuthorizationRequirementData
In the second group of the .NET 6 output (with a default scheme set) it says SecurityToken = JwtSecurityToken; on .NET 8, JsonWebToken. That is the "Security token events return a JsonWebToken" item in the .NET 8 breaking changes list: the JwtBearer, OpenIdConnect and WsFederation handlers use the faster JsonWebTokenHandler by default instead of JwtSecurityTokenHandler. The property's type is still SecurityToken, so the compiler says nothing; but code that writes (JwtSecurityToken)context.SecurityToken throws an InvalidCastException at run time, or gets null with as and silently misses claims. That answers the first Guess First question. The fix is to cast to JsonWebToken; in an emergency, JwtBearerOptions.UseSecurityTokenValidators = true restores the old behavior, but don't treat it as a permanent fix.
.NET 8's big Identity feature is MapIdentityApi<TUser>(): registration, sign-in, refresh, email confirmation and 2FA (two-factor authentication) endpoints for SPA (Single-Page Application) and mobile clients. The token it issues is not a JWT but an Identity-specific bearer token; we discussed its limits in post 03 and in the .NET 8 post of the ASP.NET Core Web API series. On the authorization side IAuthorizationRequirementData arrived: a parameterized policy used to need an attribute, a policy provider, a requirement and a handler; now a single attribute that produces the requirement directly is enough. The details of this mechanism come in post 01 of the upcoming "ASP.NET Core Security" series.
.NET 9: PAR and AdditionalAuthorizationParameters in OIDC
We used .NET 9's two contributions in this area in post 11. The OpenIdConnect handler supports PAR (Pushed Authorization Requests, RFC (Request for Comments) 9126), and the default behavior is PushedAuthorizationBehavior.UseIfAvailable: if the authorization server advertises a PAR endpoint in its discovery document, the handler uses it on its own. This is a behavior change that arrives unnoticed with the upgrade: an app moving from .NET 8 to 9 and connecting to a PAR-capable server like Keycloak has its authorization request moved to the back channel without any code change. Usually a good thing; but if a WAF (Web Application Firewall) or gateway rule in front of the authorization server blocks the PAR endpoint, sign-in breaks. AdditionalAuthorizationParameters reduced parameters such as prompt and audience, which used to be added by hand in the OnRedirectToIdentityProvider event, to a single line.
.NET 10: Passkeys, 401/403 for APIs and Metrics
The cookie, no auth: 401 line in the .NET 10 output is where the same code returned 302 on .NET 6 and 8. The release notes call it "Avoid cookie login redirects for known API endpoints": cookie authentication returns 401 instead of redirecting to the login page, and 403 instead of access denied, on endpoints that carry IApiEndpointMetadata. This metadata is added automatically to [ApiController]s, minimal API endpoints that read or write JSON, endpoints returning TypedResults, and SignalR endpoints. Note the detail in the output: the Location header is still written; only the status code changed. That answers the second Guess First question: if the panel's JavaScript followed the 302 and tried to parse the login page's HTML (HyperText Markup Language) as JSON, it now sees a 401. That is usually a fix, but if a client relies on the old behavior, you can bring the redirect back by overriding OnRedirectToLogin. In the BFF (Backend for Frontend) in post 14 we did the opposite and returned 401 in every case, because YARP (Yet Another Reverse Proxy) routes don't carry this metadata.
The last line is .NET 10's metrics: the Microsoft.AspNetCore.Authentication meter publishes authenticate duration and the counts of challenges, forbids, sign-ins and sign-outs; the Microsoft.AspNetCore.Authorization meter publishes authorization attempts. Identity also exposes metrics on its own Microsoft.AspNetCore.Identity meter, such as user creation, password checks, token generation and verification, and sign-in counts. These can be exported with OpenTelemetry; a sudden rise in challenges can be the first sign of an attack or a broken deploy.
Identity's big feature in .NET 10 is passkey support: passwordless sign-in based on WebAuthn (Web Authentication) and FIDO2 (Fast IDentity Online 2), ready in the Blazor Web App template. We set it up step by step with a client like Angular in post 07.
.NET 11: What's Coming?
.NET 11 was in its RC period while this post was written: Microsoft's support page lists RC3 (the third Release Candidate) as a go-live release, and its support ends on 10 November 2026, the day the GA (General Availability) release is expected. The following comes from the release notes, without running code. Identity now uses TimeProvider instead of DateTime for all time-related operations; token lifetimes, lockout durations and security stamp validation become testable with FakeTimeProvider. Passkey display names are inferred automatically from the authenticator's AAGUID (Authenticator Attestation Globally Unique Identifier); there are built-in mappings for Google Password Manager, iCloud Keychain, Windows Hello, 1Password and Bitwarden. dotnet user-jwts works with file-based apps through the --file option.
Authentication refresh arrives for long-lived connections: a SignalR connection can renew its token without dropping (EnableAuthenticationRefresh per hub), and Blazor Server circuits receive the refreshed ClaimsPrincipal without reconnecting. IAuthorizationRequirementData and AuthorizationPolicy metadata are applied consistently in MVC (Model-View-Controller), SignalR and Blazor's AuthorizeView. Negotiate authentication uses TLS (Transport Layer Security) channel binding over HTTPS (HTTP Secure). On the experimental side there is a server implementation of DBSC (Device Bound Session Credentials): the Microsoft.AspNetCore.Authentication.DeviceBoundSessions package sits on top of cookie authentication and binds session refresh to a private key held by the browser; think of it as the cookie-world counterpart of DPoP (Demonstrating Proof of Possession) from post 13. The package stays prerelease throughout .NET 11, and browser support is still experimental.
Migration Checklist
| Upgrade | Changed behavior | Check |
|---|---|---|
| 6 → 7 | A single scheme becomes the default automatically | With several schemes, are the defaults set explicitly? |
| 7 → 8 | SecurityToken is now JsonWebToken | Event code that casts to JwtSecurityToken |
| 7 → 8 | Claim mapping goes through JsonWebTokenHandler | Is the MapInboundClaims setting and are claim types exactly the same? |
| 8 → 9 | PAR is automatic with a PAR-capable IdP | Gateway/WAF rules, PushedAuthorizationBehavior |
| 9 → 10 | Cookie 401/403 on API endpoints | Clients relying on 302, OnRedirectToLogin |
| 9 → 10 | Authentication/authorization/Identity metrics | Dashboards and alerts |
| 10 → 11 | Identity uses TimeProvider | Tests that fake time with DateTime.UtcNow |
The cheapest way to turn the table into a test list is a probe like the one in this post: record the externally visible behavior of the authentication pipeline (status codes, headers, claim types) with the same tests before and after the upgrade. The curl outputs in every post of this series are, in fact, such a record.
In Production
Plan a major version upgrade as a separate work item from an authentication point of view: read the Security heading of each relevant release's "breaking changes" page item by item. Keep the Microsoft.IdentityModel packages (JsonWebTokens, Protocols.OpenIdConnect) compatible with the version ASP.NET Core brings; in some of the GitHub reports about claims going missing after the .NET 8 upgrade, the fix was updating an old IdentityModel package pinned by hand in the project. The lab's OIDC (OpenID Connect) handler showed x-client-ver=8.19.2.0 in its request headers; take a look at what yours says once.
Think about the LTS (Long-Term Support) and STS (Standard Term Support) calendar from a security point of view too. According to Microsoft's support page, support for .NET 8 (LTS) and .NET 9 (STS) ends on 10 November 2026, two days after this post; .NET 10 (LTS) is supported until 14 November 2028. .NET 6 and 7 no longer receive security patches at all. Authentication code is one of the areas that receives the most security patches; staying on an unsupported release weakens the ground under every defense we built in this series. That is why the .NET 8 output of this post's probe should be the first line of an upgrade list.
Common Pitfalls
First, taking "it compiles and the tests pass" to mean the upgrade is done; none of the changes in this post produce a compile error. Second, leaving UseSecurityTokenValidators = true as a permanent fix. Third, treating .NET 10's 401 behavior as a bug and bringing redirects back everywhere. Fourth, hunting sign-in problems on the authorization server without knowing PAR was switched on automatically in .NET 9. Fifth, taking preview and experimental packages (like DBSC) to production.
A Small Experiment
★☆☆ Add a JwtBearerOptions.UseSecurityTokenValidators = true line to the probe and show that the type of SecurityToken changes on .NET 10.
★★☆ Add a second endpoint returning Results.Text to the cookie app. Guess whether it returns 302 or 401 on .NET 10, then run it and explain which endpoints get IApiEndpointMetadata.
★★★ When the .NET 11 SDK (Software Development Kit) goes GA, add the net11.0 target to the probe, set up Identity with FakeTimeProvider, test a password reset token's lifetime by moving the clock forward, and add the result as a row to this post's table.
The series ends here. From credentials to sessions, cookies to passkeys, JWTs to refresh tokens, OAuth and OIDC to the BFF and authorization models, we built every piece on Bookstore with real outputs. We leave the mechanics of authentication here; we will open up ASP.NET Core's authorization framework, its policy providers and handler chain, in the upcoming "ASP.NET Core Security" series.