Picture this. Your API has [Authorize] on every controller. Login works. Tokens validate. The security review is green.

Then a curious user changes /invoices/1042 to /invoices/1043 in the URL and reads a stranger's invoice.

Nothing was "broken". Authentication did its job. Authorization just never asked the right question.

ASP.NET Core gives you a powerful, pluggable security system, and that flexibility is exactly why it feels overwhelming: cookies, JWTs, OAuth 2.0, OpenID Connect, Identity, schemes, policies, requirements, handlers. This guide is the map I wish I had when I started.

What you'll get:

  • A plain-English mental model of how ASP.NET Core security works
  • Every authentication option, with clear "use it when / avoid it when" guidance
  • Every authorization style, from [Authorize] up to resource-based checks
  • A recommended architecture for each common scenario
  • A production checklist of the things that bite teams after launch

Examples target .NET 10 (LTS). Most of it works on .NET 8 and later, and I flag version-specific behavior where it matters.

TL;DR

  • Don't build your own identity provider. Use Entra ID, Auth0, Okta, Keycloak, Duende, or OpenIddict.
  • Browser apps: cookies (and a BFF for SPAs).
  • APIs: JWT Bearer.
  • Machines: client credentials, managed identity, or mTLS.
  • Own your users? ASP.NET Core Identity, with lockout, MFA, and passkeys (.NET 10).
  • Authorization: fallback policy first, named policies next, resource-based checks for anything with an ID in the URL.
  • Production: persist Data Protection keys, validate every token field, keep tokens short-lived, rate-limit login endpoints, and test your access rules.

1. Two questions, two jobs

Think of an airport.

  • Passport control checks who you are. That is authentication.
  • The gate agent checks whether your boarding pass lets you on this flight. That is authorization.
  • Passing the first says nothing about the second. And even with a valid boarding pass, you still can't sit in someone else's seat. That last check is resource-based authorization, and it is the one most apps forget.
Authentication (AuthN) Authorization (AuthZ)
Question Who are you? What are you allowed to do?
Result A ClaimsPrincipal Allowed, challenged (401), or forbidden (403)
Middleware UseAuthentication() UseAuthorization()
Configured with Schemes and handlers Policies, requirements, handlers

The HTTP status codes follow the same split:

  • 401 Unauthorized means "I don't know who you are." (Not authenticated.)
  • 403 Forbidden means "I know who you are, and the answer is no." (Not authorized.)

2. The vocabulary, in plain English

You will see these terms constantly. Here is what they mean without the jargon.

Term Plain-English meaning
Claim One fact about a user: email = ada@example.com, role = Admin
ClaimsPrincipal The "user object" in ASP.NET Core. A bag of claims, available as HttpContext.User
Authentication scheme A named way of proving identity: "Cookies", "Bearer", "OpenIdConnect"
Authentication handler The code behind a scheme that reads the request and builds the principal
Policy A named rule, such as "CanRefund", that decides if a user may proceed
Identity provider (IdP) A dedicated service that logs users in and issues tokens (Entra ID, Auth0, Keycloak...)
Access token A short-lived "key card" the client shows to an API
JWT A common token format: signed JSON that an API can verify without calling anyone
OAuth 2.0 / OIDC OAuth 2.0 delegates access. OpenID Connect adds login (who the user is) on top
PKCE A protection that makes the authorization-code flow safe for browsers and mobile apps
BFF Backend-for-Frontend: a small server that keeps tokens away from the browser

3. How it fits together in ASP.NET Core

Every secured app has the same skeleton:

var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication(/* default scheme */) .AddXxx(/* handler options */); builder.Services.AddAuthorization(/* policies */); var app = builder.Build(); app.UseHttpsRedirection(); app.UseCors(); // before auth, or preflight requests fail app.UseAuthentication(); // who are you? app.UseAuthorization(); // what can you do? app.MapGet("/secure", () => "hi").RequireAuthorization(); app.Run(); 

Order matters. UseAuthentication() must run before UseAuthorization(), and both must run before the endpoints they protect. Swap them and everything looks anonymous.

A scheme can play several roles:

  • Authenticate: build the user from the request
  • Challenge: respond to an anonymous user hitting a protected resource (cookies redirect to a login page; JWT returns 401)
  • Forbid: respond to a known user who lacks permission (403)
  • SignIn / SignOut: create or clear the session (cookies)

4. Pick your approach in 60 seconds

Start with one question: who is calling?

Who is calling? | +-- A person in a browser | +-- Server-rendered pages (MVC, Razor Pages, Blazor Server) | | --> Cookie (+ Identity if you own the users, or OIDC for SSO) | +-- A SPA (React, Angular, Vue, Blazor WebAssembly) | --> BFF: cookie to the browser, OIDC behind it | +-- A mobile or desktop app | --> OIDC authorization code + PKCE, then JWT to your API | +-- Another service (no user involved) | --> Client credentials or managed identity (+ mTLS for high trust) | +-- A third-party developer --> OAuth client credentials (or API keys if you need it simple) 

And the same thing as a table:

Scenario Recommended approach
Server-rendered app, you own the user database Cookie + ASP.NET Core Identity
Server-rendered app in a company with SSO Cookie + OpenID Connect
SPA with your own backend BFF: HttpOnly cookie to the browser, OIDC (code + PKCE) to the IdP
Mobile / native app OIDC code + PKCE in the system browser; bearer tokens to the API
Web API consumed by your apps JWT Bearer, validated against your IdP
Service-to-service OAuth 2.0 client credentials, or managed / workload identity in the cloud
High-trust internal services mTLS (client certificates), often alongside tokens
Public API for third parties Client credentials; API keys for simple cases
Windows intranet Negotiate (Kerberos / NTLM)
Passwordless for end users Passkeys (built into Identity since .NET 10)
Inbound webhooks HMAC signature verification (this is request verification, not user auth)

The single most important recommendation

Do not build your own token issuer unless identity is your product.

Use a dedicated identity provider:

  • Managed: Microsoft Entra ID / Entra External ID, Auth0, Okta, AWS Cognito, Firebase Auth
  • Self-hosted: Keycloak, Duende IdentityServer (commercial licensing), OpenIddict (open source)

IdentityServer4 is no longer maintained, so don't start new projects on it.

Why this matters: your apps become clients and resource servers of the IdP. That is a much smaller attack surface than being the IdP, which means owning password storage, MFA, token signing, key rotation, and account recovery.

5. Authentication mechanisms, one by one

5.1 Cookie authentication

After login, the server issues an encrypted, signed cookie that holds the user's claims. The browser sends it back automatically on every request.

  • ✅ Use it when: your UI is served by the same site (MVC, Razor Pages, Blazor Server, or a BFF)
  • ❌ Avoid it when: you are building an API for third-party or mobile clients
builder.Services .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.Cookie.Name = "__Host-myapp"; // __Host- prefix: Secure, Path=/, no Domain options.Cookie.HttpOnly = true; // JavaScript can't read it options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.Cookie.SameSite = SameSiteMode.Lax; // good default; Strict can break login redirects options.ExpireTimeSpan = TimeSpan.FromHours(8); options.SlidingExpiration = true; options.LoginPath = "/account/login"; options.AccessDeniedPath = "/account/denied"; }); 

SecurePolicy.Always means the cookie only travels over HTTPS. Run your dev server with HTTPS (dotnet dev-certs https --trust) so local testing matches production.

Signing a user in manually:

var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, user.Id), new(ClaimTypes.Name, user.Email), new(ClaimTypes.Role, "Admin") }; var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignInAsync( new ClaimsPrincipal(identity), new AuthenticationProperties { IsPersistent = true }); 

Production tips

  • Add antiforgery protection for state-changing requests (app.UseAntiforgery() in .NET 8+, and AutoValidateAntiforgeryToken in MVC).
  • With multiple instances you must persist Data Protection keys (see the checklist), or users get logged out at random.
  • The whole principal is serialized into the cookie, so keep claims lean. For large claim sets, use a server-side session store (ITicketStore).

.NET 10 behavior change you should know about. For known API endpoints, the cookie handler now returns 401/403 instead of redirecting to the login page. Known API endpoints are [ApiController] endpoints, minimal API endpoints that read or write JSON, endpoints returning TypedResults, and SignalR endpoints. Regular pages still redirect. The 401/403 response still carries a Location header with the login URL, and there is an opt-out (AllowCookieRedirect) if you rely on the old behavior. Check any browser code that used to follow those redirects.

5.2 ASP.NET Core Identity

Identity is a full membership system: a user store (usually EF Core), password hashing, roles, claims, lockout, email confirmation, password reset, external logins, two-factor authentication (2FA), and, from .NET 10, passkeys.

  • ✅ Use it when: you own the user database and need standard account management
  • ❌ Avoid it when: your users live in an external IdP. In that case you only need OIDC, not a user store.
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(builder.Configuration.GetConnectionString("Default"))); builder.Services .AddIdentity<AppUser, IdentityRole>(options => { options.Password.RequiredLength = 12; options.Password.RequireNonAlphanumeric = false; // length beats complexity rules options.User.RequireUniqueEmail = true; options.SignIn.RequireConfirmedEmail = true; options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); options.Lockout.AllowedForNewUsers = true; }) .AddEntityFrameworkStores<AppDbContext>() .AddDefaultTokenProviders(); builder.Services.ConfigureApplicationCookie(options => { options.Cookie.HttpOnly = true; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.ExpireTimeSpan = TimeSpan.FromHours(8); options.SlidingExpiration = true; }); 

AddIdentity gives you users and roles. If you only need the core services (for example, in an API), AddIdentityCore is the lighter option.

Logging in with lockout turned on:

var result = await signInManager.PasswordSignInAsync( email, password, isPersistent: false, lockoutOnFailure: true); if (result.RequiresTwoFactor) { /* send the user to the 2FA page */ } if (result.IsLockedOut) { /* show a lockout message */ } 

Worth knowing

  • Passwords are hashed with PBKDF2 by default. Never write your own hashing.
  • 2FA (TOTP) is built in: authenticator-app codes plus recovery codes.
  • Passkeys (.NET 10): Identity now supports passkey (WebAuthn/FIDO2) registration and sign-in, and the Blazor Web App template ships with it. Passkeys use public-key cryptography tied to your site's origin, so there is no password to phish. Adopting them needs a database migration for the passkey table. Note that the default template still asks users to create a password first. That is a template choice, not an Identity limitation.
  • MapIdentityApi<TUser>() (.NET 8+) gives you ready-made register, login, and refresh endpoints. The tokens it issues are Identity's own bearer tokens, not JWTs, and it is not a full OAuth/OIDC server. It is great for demos and simple single-client backends. For multiple clients or third parties, use a real IdP.

5.3 JWT Bearer authentication

The client sends Authorization: Bearer <jwt>. The API verifies the signature, issuer, audience, and expiry, then builds a principal from the token's claims. There is no server-side session.

  • ✅ Use it when: you are protecting a Web API called by mobile apps, other services, or a BFF
  • ❌ Avoid it when: you would be tempted to store the token in browser localStorage (use a BFF instead)

The right way is to validate tokens issued by your IdP. Set Authority, and ASP.NET Core downloads the signing keys and handles key rotation for you.

builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = "https://login.example.com/"; // IdP issuer URL options.Audience = "orders-api"; // this API's identifier options.MapInboundClaims = false; // keep original claim names ("sub", "role", ...) options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ClockSkew = TimeSpan.FromSeconds(30), // default is 5 minutes; tighten it NameClaimType = "name", RoleClaimType = "role" }; options.RequireHttpsMetadata = true; // never disable in production }); 

Production tips

  • Never set ValidateIssuer, ValidateAudience, or ValidateLifetime to false to "make it work". That is how authentication bypasses happen. If validation fails, fix the configuration.
  • Keep access tokens short-lived: minutes, not days. Use refresh tokens with rotation in a client that can store them safely.
  • A JWT cannot be revoked before it expires (unless you add introspection or a deny-list). Short lifetimes are your safety net.
  • A JWT is signed, not encrypted. Anyone holding it can read it, so never put secrets in it.
  • Prefer asymmetric signing (RS256 / ES256), so APIs only need a public key.
  • If you must issue your own JWTs, keep signing keys in a vault (Key Vault, KMS, Secrets Manager, HashiCorp Vault), rotate them, and publish a JWKS endpoint.

5.4 OpenID Connect (OIDC) and OAuth 2.0

OAuth 2.0 handles delegated access (access tokens). OIDC sits on top and adds login (the ID token and standard user claims). For interactive login against an external IdP, you want OIDC.

  • ✅ Use it when: you want SSO, enterprise login, or social login
  • Flow to use: authorization code + PKCE. The implicit flow and the password (ROPC) flow are discouraged by current security guidance and removed in OAuth 2.1, so avoid them.
builder.Services .AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie() .AddOpenIdConnect(options => { options.Authority = "https://login.example.com/"; options.ClientId = builder.Configuration["Oidc:ClientId"]; options.ClientSecret = builder.Configuration["Oidc:ClientSecret"]; // from a vault, not appsettings.json options.ResponseType = "code"; options.UsePkce = true; options.Scope.Clear(); options.Scope.Add("openid"); options.Scope.Add("profile"); options.Scope.Add("email"); options.Scope.Add("offline_access"); // lets you get refresh tokens options.Scope.Add("orders-api"); // your API scope (the name depends on your IdP) options.SaveTokens = true; // keep the tokens with the session options.GetClaimsFromUserInfoEndpoint = true; options.MapInboundClaims = false; }); 

If you use Microsoft Entra ID, the Microsoft.Identity.Web library wraps all of this, including token acquisition, caching, and downstream API calls. Pick the one that matches your app:

// A web app that signs users in builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd")); // OR a web API that validates tokens builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")); 

Social logins are OAuth/OIDC handlers added with calls like .AddGoogle(), .AddMicrosoftAccount(), and .AddFacebook(), usually combined with Identity's external-login flow. Providers such as GitHub are not built in, but community packages (for example AspNet.Security.OAuth.GitHub) add them.

Production tips

  • Keep client secrets out of source control. Prefer certificates or managed identity where your IdP supports them.
  • Register exact redirect URIs. No wildcards.
  • Behind a reverse proxy or load balancer, configure forwarded headers so redirect URIs use the public scheme and host. Forgetting this is the classic cause of redirect_uri_mismatch.

5.5 The BFF pattern for SPAs (recommended)

Storing tokens in localStorage or sessionStorage means any XSS bug can steal them. The current best practice for browser apps is the Backend-for-Frontend (BFF):

 Browser ---(HttpOnly cookie)---> BFF (ASP.NET Core) ---(access token)---> Your APIs | +---(OIDC: code + PKCE)---> Identity Provider 
  • The BFF is a confidential OIDC client. Tokens never reach the browser.
  • The browser only holds a session cookie.
  • The BFF attaches the access token when it forwards API calls (for example with YARP or Duende BFF).
  • Add CSRF protection for the cookie-authenticated endpoints, such as requiring a custom header (X-CSRF: 1) together with SameSite cookies.

✅ Use it when you have a SPA (including Blazor WebAssembly) handling sensitive data and you control the backend. It adds a moving part, but it removes the biggest browser-side token risk.

5.6 Service-to-service: client credentials, managed identity, mTLS

For backend callers with no user, use the OAuth 2.0 client credentials flow. The calling service gets a token from the IdP using its own credentials and sends it as a Bearer token. The receiving API validates it with plain JWT Bearer, as above.

In the cloud, prefer managed identity / workload identity so there is no secret to leak.

For mutual TLS (client certificates):

builder.Services .AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme) .AddCertificate(options => { options.AllowedCertificateTypes = CertificateTypes.Chained; options.RevocationMode = X509RevocationMode.Online; options.Events = new CertificateAuthenticationEvents { OnCertificateValidated = ctx => { // Check thumbprint/subject against an allow-list, then build the principal. // BuildPrincipalFrom is your own helper. ctx.Principal = BuildPrincipalFrom(ctx.ClientCertificate); ctx.Success(); return Task.CompletedTask; } }; }); builder.WebHost.ConfigureKestrel(k => k.ConfigureHttpsDefaults(h => h.ClientCertificateMode = ClientCertificateMode.AllowCertificate)); 

Careful with RequireCertificate. Setting it globally forces every client, browsers included, to present a certificate. If the same app serves browsers, use AllowCertificate (as above) and protect only the machine-to-machine endpoints, or run the mTLS API on its own port or host.

If TLS ends at a proxy (NGINX, Application Gateway, Envoy), use the certificate-forwarding middleware (AddCertificateForwarding) and only trust the forwarded certificate from your known proxy.

5.7 API keys

ASP.NET Core has no built-in API key scheme, but a custom handler is short. API keys are acceptable for simple server-to-server integrations, but remember: they are static bearer secrets with no built-in expiry, scopes, or user identity. Prefer OAuth client credentials when you can.

public sealed class ApiKeyHandler( IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IApiKeyStore store) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) { public const string SchemeName = "ApiKey"; protected override async Task<AuthenticateResult> HandleAuthenticateAsync() { if (!Request.Headers.TryGetValue("X-Api-Key", out var values)) return AuthenticateResult.NoResult(); // not our scheme; let others try // The store hashes the incoming key and looks it up by hash. var client = await store.ValidateAsync(values.ToString(), Context.RequestAborted); if (client is null) return AuthenticateResult.Fail("Invalid API key"); var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, client.Id), new Claim("client_name", client.Name), new Claim("scope", string.Join(' ', client.Scopes)) }, SchemeName); var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName); return AuthenticateResult.Success(ticket); } } // Registration builder.Services.AddAuthentication() .AddScheme<AuthenticationSchemeOptions, ApiKeyHandler>(ApiKeyHandler.SchemeName, _ => { }); 

Production tips

  • Generate keys with a cryptographically secure random generator (at least 32 bytes).
  • Show the key to the owner once, and store only a hash. Because these keys are long and random, a fast hash such as SHA-256 is fine (unlike passwords, which need a slow hash).
  • Support rotation and revocation, rate-limit per key, and send keys in a header, never in the query string (URLs end up in logs).

5.8 Windows / Negotiate

For intranet apps on Active Directory, the Microsoft.AspNetCore.Authentication.Negotiate package gives you Kerberos/NTLM single sign-on:

Related posts