Authentication in ASP.NET Core answers one question for every request: who is calling? The framework separates that question cleanly from authorization (what are they allowed to do), and it supports it through a pluggable set of schemes rather than one fixed login mechanism. This guide covers the three schemes you will use in almost every production app: cookies for server-rendered UIs, JWT bearer tokens for APIs, and OpenID Connect for interactive sign-in against Microsoft Entra ID and other identity providers, plus token refresh, the backend-for-frontend (BFF) pattern for single-page apps, and the mistakes that most often break authentication in production.
What Is Authentication in ASP.NET Core?#
ASP.NET Core models authentication as a set of named schemes, each backed by a handler that implements IAuthenticationHandler. A scheme knows how to do up to four things: authenticate an incoming request (read a cookie or a bearer token and build a ClaimsPrincipal), challenge an anonymous caller (redirect to a login page or return a 401), sign a principal in (write a cookie), and sign a principal out. You register schemes once, in Program.cs, and the framework picks a handler either explicitly ([Authorize(AuthenticationSchemes = "...")]) or through the defaults you configure with AddAuthentication:
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options => { /* configured below */ });DefaultScheme decides which handler runs on every request to populate HttpContext.User. DefaultChallengeScheme decides which handler responds when an anonymous user hits a protected endpoint. Splitting the two is what lets a cookie-based app redirect to an external identity provider on challenge while still reading its own session cookie on every other request. An app can register any number of schemes side by side, for example a cookie scheme for browser users and a JWT bearer scheme for its API surface.
How the Authentication Middleware Works#
Authentication is middleware, and its position in the pipeline matters. UseAuthentication must run after UseRouting (so endpoint metadata is available) and before UseAuthorization and UseCors if you enable both:
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();UseAuthentication does not reject anonymous requests by itself; it only tries to build a ClaimsPrincipal from whatever credential is present and attaches it to HttpContext.User. Rejection is UseAuthorization's job. When an endpoint requires authentication and none was provided, the authorization middleware calls ChallengeAsync on the default challenge scheme, which is where a JWT bearer handler returns 401 Unauthorized and an OpenID Connect or cookie handler instead issues a redirect. WebApplication adds UseAuthentication and UseAuthorization automatically once you register their services, immediately before UseEndpoints, which is one reason middleware order still catches teams by surprise the first time they add UseCors or UsePathBase; see the ASP.NET Core middleware pipeline guide for the full ordering rules.
Getting Started: A Minimal Authenticated API#
The smallest realistic setup adds one scheme and requires it everywhere except a health check:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = builder.Configuration["Auth:Audience"];
});
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/healthz", () => "Healthy").AllowAnonymous();
app.MapGet("/me", (ClaimsPrincipal user) => user.Identity?.Name);
app.Run();SetFallbackPolicy requires an authenticated user for any endpoint that carries no authorization metadata at all, which is a secure-by-default posture: new endpoints are protected unless someone explicitly calls AllowAnonymous(). This differs from the default policy, which only applies to endpoints that opt in with a bare [Authorize] or RequireAuthorization().
JWT Bearer Authentication for APIs#
APIs that are called by other services, mobile apps or single-page apps typically validate a JWT access token instead of a cookie, because the token is not tied to a browser origin. Install the Microsoft.AspNetCore.Authentication.JwtBearer package and configure AddJwtBearer against the identity provider that issued the token:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = builder.Configuration["Auth:Audience"];
options.MapInboundClaims = false; // Keep the original JWT claim names ("sub", not "nameidentifier")
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidIssuers = builder.Configuration.GetSection("Auth:ValidIssuers").Get<string[]>(),
ValidAudiences = builder.Configuration.GetSection("Auth:ValidAudiences").Get<string[]>(),
};
});Authority points the handler at the provider's OpenID Connect metadata document, from which it downloads the signing keys used to validate ValidateIssuerSigningKey. At minimum, validate the signature, the issuer (iss), the audience (aud) and the expiration (exp); a token that fails any of these should never reach your business logic. A 401 means the token itself is invalid or missing; a 403 means the token is valid but the caller lacks permission, which is an authorization concern, not an authentication one β see authorization in ASP.NET Core for policies and roles. For local development, the dotnet user-jwts tool issues short-lived signed tokens without standing up a real identity provider:
dotnet user-jwts create --claim role=Admin --audience https://localhost:5001Supporting Multiple Token Issuers#
An API that accepts tokens from more than one issuer β for example, first-party and partner tenants β registers multiple named JWT bearer schemes and picks between them with AddPolicyScheme, which inspects the request before any handler runs:
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "PickScheme";
options.DefaultChallengeScheme = "PickScheme";
})
.AddJwtBearer("Tenant", options => options.Authority = "https://tenant.example.com")
.AddJwtBearer("Partner", options => options.Authority = "https://partner.example.com")
.AddPolicyScheme("PickScheme", "PickScheme", options =>
{
options.ForwardDefaultSelector = context =>
{
var authHeader = context.Request.Headers.Authorization.ToString();
return authHeader.Contains("partner-") ? "Partner" : "Tenant";
};
});Each API endpoint can also pin an explicit scheme with [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] when only one of several registered schemes should apply to it.
OpenID Connect for Interactive Sign-In#
OpenID Connect (OIDC) is how a server-rendered app authenticates a human through an external identity provider using the authorization code flow with PKCE, then stores the result in a local cookie. Register AddOpenIdConnect alongside a cookie scheme, because OIDC handles the challenge and the redirect while the cookie carries the session for every subsequent request:
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = builder.Configuration["Oidc:Authority"];
options.ClientId = builder.Configuration["Oidc:ClientId"];
options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code;
options.SaveTokens = true;
options.Scope.Add("offline_access"); // Requests a refresh token
options.TokenValidationParameters.NameClaimType = "name";
options.TokenValidationParameters.RoleClaimType = "role";
});Public (browserless-secret) OIDC clients are no longer recommended for web apps; use a confidential client with a client secret or certificate, which is what the code above does. Since .NET 9, the OpenID Connect handler sends an OAuth 2.0 Pushed Authorization Request (PAR) by default whenever the provider advertises support for it, which moves the authorization parameters out of the browser's URL bar and into a direct back-channel call; you can force it with options.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Require.
Microsoft Entra ID and Other Identity Providers#
For Microsoft Entra ID or Microsoft Entra External ID, use the Microsoft.Identity.Web package instead of configuring AddOpenIdConnect by hand. It layers Microsoft-specific behavior, such as instance/tenant discovery and on-behalf-of token acquisition for downstream APIs, on top of the same ASP.NET Core OIDC handler:
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("EntraId"));Other providers, including Okta, Auth0 and self-hosted servers such as Keycloak or OpenIddict, work with the plain AddOpenIdConnect handler because they all speak the same OIDC and OAuth 2.0 standards; only the Authority, client credentials and a handful of provider-specific options change. See choosing an identity stack for a full comparison of the providers themselves. If you are running multiple OIDC clients from different providers in one app, keep them on the default ASP.NET Core implementation rather than mixing in a provider-specific package, because each package tends to override options that the others depend on.
Token Refresh#
SaveTokens = true stores the access, ID and refresh tokens from the OIDC handshake inside the authentication cookie's properties, retrievable with HttpContext.GetTokenAsync("access_token"). It does not refresh the access token automatically when it expires; your app has to detect an expired token and call the provider's token endpoint with the stored refresh token, or reject the request and force a new challenge. For Blazor apps, the documented pattern implements this refresh manually inside a custom CookieAuthenticationEvents.OnValidatePrincipal handler; for other app types, the third-party Duende.AccessTokenManagement.OpenIdConnect package automates the same rotation. MapIdentityApi<TUser>'s own bearer tokens work differently: call its /refresh endpoint with the stored refresh token before the access token (governed by BearerTokenOptions.BearerTokenExpiration) runs out.
The BFF Pattern for SPAs and Blazor WebAssembly#
Running an OIDC public client entirely inside a single-page app's JavaScript means storing access and refresh tokens somewhere the browser can reach them, which the OAuth 2.0 for Browser-Based Applications draft specification advises against. The backend for frontend (BFF) pattern avoids this: a small ASP.NET Core backend performs the entire OIDC code flow itself, stores the tokens server-side, and issues the browser only a HttpOnly, SameSite=Strict session cookie. Every API call from the SPA goes to the BFF, which attaches the real access token before forwarding the request, often through YARP as a reverse proxy:
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapReverseProxy(); // Forwards to downstream APIs, attaching the session's access tokenBlazor Web App and Blazor WebAssembly apps that need OIDC follow the same shape: the interactive client never sees a token, only the session cookie, and Duende's Duende.BFF package (commercially licensed, alongside Duende IdentityServer) adds session management, CSRF protection and token forwarding on top of this base pattern if you need it out of the box rather than hand-rolled.
Best Practices#
- Prefer cookies for browsers, bearer tokens for machine-to-machine and mobile clients. Cookies get
HttpOnlyandSameSiteprotection for free; tokens handled by JavaScript do not. - Always validate issuer, audience, signature and expiration on bearer tokens. Never disable
ValidateIssuerSigningKeyoutside a throwaway test. - Use a confidential OIDC client with PKCE, never a public client, for any web application.
- Keep
Authoritypointed at metadata, not a hardcoded key. Handlers refresh signing keys automatically when they rotate at the provider. - Set a fallback authorization policy so newly added endpoints are protected by default, and use
[AllowAnonymous]explicitly for the exceptions. - Adopt the BFF pattern for SPAs and mobile web views instead of storing tokens in
localStorageorsessionStorage.
Common Pitfalls#
- Treating
MapIdentityApitokens as JWTs. They are proprietary bearer tokens meant for simple first-party scenarios, not a general-purpose token service; don't try to decode or validate them as JWTs downstream. - Wrong middleware order.
UseAuthentication/UseAuthorizationplaced beforeUseRouting, orUseCorsplaced after them, silently changes which requests get authenticated or allowed. - Storing access tokens in browser storage.
localStorageis readable by any script on the page, which turns a single cross-site scripting bug into full account takeover. - Assuming
SaveTokensrefreshes tokens. It only persists what the provider issued at sign-in; expiration still needs explicit handling. - Skipping
AllowAnonymouson the OIDC callback and sign-out paths after adding a fallback policy, which breaks the login redirect itself. - Ignoring clock skew. Token validation allows a default grace window around
expandnbf; server clocks that drift further than that will reject otherwise-valid tokens.
Frequently Asked Questions#
What is the difference between authentication and authorization in ASP.NET Core?#
Authentication establishes who is calling by building a ClaimsPrincipal from a credential such as a cookie or bearer token. Authorization decides what that principal is allowed to do, using roles, claims or policies evaluated against the populated HttpContext.User. Authentication middleware always runs first; see authorization in ASP.NET Core for how the two connect.
Can one ASP.NET Core app use both cookies and JWT bearer tokens?#
Yes. Register both schemes with AddAuthentication().AddCookie().AddJwtBearer(), then pick per endpoint with [Authorize(AuthenticationSchemes = "...")], or use AddPolicyScheme to select automatically based on the request, for example routing API paths to the bearer scheme and everything else to cookies.
Why does my API return 401 instead of 403 for an authenticated but unauthorized user?#
A 401 means the credential itself was rejected β missing, expired or invalid signature. A 403 means authentication succeeded but an authorization policy failed. If a valid caller is getting 401, check the JWT's issuer and audience against ValidIssuers/ValidAudiences before looking at authorization policies at all.
Should I still build a single-page app as an OpenID Connect public client?#
No. Public clients keep tokens in the browser, which is exposed to any cross-site scripting bug. The BFF pattern β a small backend that performs the OIDC flow and hands the browser only a session cookie β is the current recommendation for both SPAs and Blazor WebAssembly apps that need external sign-in.
How do I test endpoints that require authentication?#
For integration tests, replace the real scheme with a test AuthenticationHandler registered only in the test host, so WebApplicationFactory requests carry a predictable, fast ClaimsPrincipal without hitting a real identity provider. For manual testing of JWT-protected APIs, dotnet user-jwts create issues a real, signed token scoped to your app's configuration.
Summary#
- Authentication in ASP.NET Core is scheme-based: cookies for browsers, JWT bearer for APIs, OpenID Connect for interactive sign-in against an external provider, all pluggable side by side.
UseAuthenticationpopulatesHttpContext.User;UseAuthorizationdecides whether the request proceeds.- Validate every JWT's issuer, audience, signature and expiration; never treat
MapIdentityApi's bearer tokens as JWTs. - Use a confidential OIDC client with PKCE, and adopt the BFF pattern rather than storing tokens in the browser for SPAs and Blazor WebAssembly.
- Token refresh is not automatic; plan for it explicitly, whether through
MapIdentityApi's/refreshendpoint or a package such asDuende.AccessTokenManagement.OpenIdConnect.