Authentication and Authorization Best Practices in ASP.NET Core
A friendly guide to authentication and authorization in ASP.NET Core for .NET 10 — JWT, cookies, claims, roles, policies, and security best practices with diagrams.
The school gate and the library card
Picture your school. At the front gate, a guard checks your ID card to make sure you really are a student here. That is authentication — proving who you are.
Once you are inside, not every door is open to you. The staff room, the principal's office, the exam paper cupboard — these need special permission. The guard knowing your name does not mean you can walk anywhere. That is authorization — deciding what you are allowed to do.
Software works the same way. First the app checks who you are. Then it checks what you may do. ASP.NET Core gives you clean tools for both, and in this guide we will learn how to use them the right way for .NET 10.
Keep this one sentence in your head the whole time:
Authentication = "Who are you?" Authorization = "What can you do?"
Two steps, always in this order
You can never decide what a person is allowed to do until you know who they are. So authentication always comes first, then authorization.
Notice the two different error codes. They are not the same, and mixing them up confuses everyone:
| Status | Meaning | When it happens |
|---|---|---|
| 401 Unauthorized | "I do not know who you are" | No token, expired token, bad password |
| 403 Forbidden | "I know you, but you cannot do this" | Logged in, but missing the right role or claim |
A common beginner mistake is returning 401 when the real answer is 403. If the user is signed in but lacks permission, send 403. Save 401 for "please sign in first".
How authentication actually works inside ASP.NET Core
ASP.NET Core has a small, tidy system for this. At the center is a service called IAuthenticationService, which sits behind the UseAuthentication() middleware.
When a request arrives, the middleware calls AuthenticateAsync on the default scheme. A scheme is just a named configuration that points to a specific handler — JWT bearer, cookie, OAuth, and so on. The handler reads the request (usually the Authorization header for tokens, or a cookie), checks the credential, and if all is well it builds a ClaimsPrincipal. That principal is the "ID card" the rest of your app reads.
The authentication pipeline
Steps
Request
Header or cookie arrives
Middleware
UseAuthentication runs
Scheme + Handler
Default scheme chosen
Validate
Credential checked
ClaimsPrincipal
User identity built
The most important rule about order: call UseAuthentication() before any middleware that needs to know the user, and UseAuthorization() right after it. If you get this order wrong, the user will look like a stranger to your authorization rules.
var app = builder.Build();
// Order matters a lot here.
app.UseAuthentication(); // figure out WHO the user is
app.UseAuthorization(); // figure out WHAT they may do
app.MapControllers();
app.Run();Claims: the small facts about a user
Once a user is authenticated, ASP.NET Core does not just remember "Aisha is logged in". It stores a set of claims. A claim is a tiny fact, like a key and value: name = "Aisha", role = "Teacher", department = "Science".
Think of claims as the printed details on your ID card. The card does not store your whole life — just the few facts the school needs. Your authorization rules then read these claims to make decisions.
| Claim type | Example value | Used for |
|---|---|---|
| Name | "Aisha" | Showing who is signed in |
| Role | "Admin" | Role-based checks |
| sub (subject) | "user-1042" | The unique user id |
| Custom (e.g. DateOfBirth) | "2010-04-01" | Custom policy rules |
Choosing the right authentication scheme
You do not pick a scheme at random. It depends on what kind of app you are building.
- Cookie authentication suits traditional websites and Blazor apps. The browser stores the cookie and sends it back automatically, and it only goes to the issuing domain, which is safe.
- JWT bearer tokens suit APIs. The identity provider gives the client a signed token after login. The client puts it in the
Authorization: Bearer ...header on each request. Tokens are not tied to one domain, so they work well across services. - OpenID Connect (OIDC) is for "log in with someone else", such as Entra ID, Auth0, Google, or Okta.
Setting up JWT bearer authentication
For APIs, JWT is the everyday choice. Here is a clean setup for .NET 10 using the Microsoft.AspNetCore.Authentication.JwtBearer package. The key idea is token validation — you must tell the app to check the signature, issuer, audience, and expiry. Skipping any of these is a real security hole.
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true, // reject expired tokens
ValidateIssuerSigningKey = true, // reject tampered tokens
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)),
ClockSkew = TimeSpan.FromSeconds(30) // small allowance, not the default 5 min
};
});
builder.Services.AddAuthorization();A few best-practice notes on this code:
- Never hard-code the signing key. Read it from configuration or, better, a secrets store like Azure Key Vault or environment variables.
- Always set
ValidateLifetime = trueso expired tokens are rejected. - Reduce
ClockSkew. The default is 5 minutes, which quietly extends every token's life. A few seconds is plenty. - Keep access tokens short-lived (minutes), and use refresh tokens for longer sessions.
How a token request flows
It helps to see the whole journey of a single API call once a user has a token.
Authorization: from simple to powerful
ASP.NET Core gives you three levels of authorization. Start simple and grow only when you need to.
1. Just require login
The simplest rule: the user must be signed in. Add [Authorize] with no extra options.
[Authorize]
[HttpGet("/profile")]
public IActionResult MyProfile() => Ok(GetCurrentUser());In a Minimal API, you do the same with .RequireAuthorization():
app.MapGet("/profile", () => Results.Ok(GetCurrentUser()))
.RequireAuthorization();2. Role-based authorization
Roles are broad groups like "Admin" or "Teacher". This is the classic, easy approach.
[Authorize(Roles = "Admin")]
[HttpDelete("/users/{id}")]
public IActionResult DeleteUser(int id) => Ok();Roles are great when your permissions are coarse. But they get messy when rules become detailed ("editors can edit only their own posts on weekdays"). For that, reach for policies.
3. Policy-based authorization
A policy is a named rule built from one or more requirements. Handlers check the user's claims against those requirements. This is the most flexible model and the one Microsoft recommends for anything beyond simple roles.
Here is a policy that needs a user to be at least 18, based on a date-of-birth claim.
public class MinimumAgeRequirement(int age) : IAuthorizationRequirement
{
public int Age { get; } = age;
}
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
{
var dobClaim = context.User.FindFirst("DateOfBirth");
if (dobClaim is null) return Task.CompletedTask; // fail quietly
var dob = DateTime.Parse(dobClaim.Value);
var age = DateTime.Today.Year - dob.Year;
if (dob > DateTime.Today.AddYears(-age)) age--;
if (age >= requirement.Age)
context.Succeed(requirement); // pass!
return Task.CompletedTask;
}
}Register the policy and handler in Program.cs:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("MustBeAdult", policy =>
policy.Requirements.Add(new MinimumAgeRequirement(18)));
});
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();Then use it by name:
[Authorize(Policy = "MustBeAdult")]
[HttpGet("/adult-content")]
public IActionResult AdultOnly() => Ok();How a policy decides
Steps
Policy
Named rule, e.g. MustBeAdult
Requirement
Min age = 18
Handler
Runs the check
Claims
Reads DateOfBirth
Decision
Succeed or fail
The single best default: secure by default
Here is a habit that prevents whole classes of bugs. Instead of remembering to add [Authorize] to every new endpoint, make everything require login by default, and then open up the few public endpoints on purpose.
You do this with a fallback policy. It applies to any endpoint that does not declare its own rule.
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});Now a brand-new controller a teammate forgot to protect is still protected. To make something public, you opt out clearly:
[AllowAnonymous]
[HttpGet("/health")]
public IActionResult Health() => Ok("Healthy");This "deny by default, allow on purpose" approach is far safer than "allow by default, remember to deny". Forgetting to add a rule should make an endpoint more locked down, not less.
More everyday security best practices
Good auth code is only part of the story. These habits matter just as much:
- Always use HTTPS. Tokens and cookies sent over plain HTTP can be stolen. Add
app.UseHttpsRedirection()and enable HSTS in production. - Never store passwords in plain text. Use ASP.NET Core Identity, which hashes passwords for you with a strong algorithm.
- Keep access tokens short and use refresh tokens for long sessions, with the ability to revoke them.
- Mark cookies
HttpOnlyandSecure. This keeps JavaScript from reading them and stops them travelling over HTTP. - Validate everything in a JWT — issuer, audience, lifetime, and signature. Do not turn validations off "just to make it work".
- Do not put secrets in source code. Use user-secrets in development and a secret store in production.
- Return the right status codes — 401 for "not signed in", 403 for "not allowed".
- Log auth failures, but never log passwords, tokens, or full cookie values.
What is new in .NET 10
.NET 10 is the current LTS release, and it brings real improvements for auth. ASP.NET Core Identity now has built-in passkey (WebAuthn) registration and sign-in, included by default in the Blazor Web App template. Passkeys let users sign in with a fingerprint or face instead of a password, which is both friendlier and more secure.
There is also clearer end-to-end guidance for connecting Blazor Web Apps to OIDC providers like Entra ID, Auth0, or Okta — including how to refresh tokens correctly and how to pass the user's identity from an interactive component down to server-side services. If you are building something new in C# 14 on .NET 10, these are worth using.
A small note on the wider ecosystem: some popular libraries such as MediatR and MassTransit have moved to commercial licensing. They are not part of authentication itself, but if your auth pipeline uses them for messaging, check the license terms before you ship.
A quick comparison: cookies vs JWT
| Feature | Cookie auth | JWT bearer |
|---|---|---|
| Best for | Server-rendered web, Blazor | APIs, SPAs, mobile |
| Where stored | Browser cookie | Client memory or storage |
| Sent automatically | Yes, by the browser | No, you add the header |
| Crosses domains | No | Yes |
| Revocation | Easy (server session) | Needs extra work (refresh tokens) |
Quick recap
- Authentication answers "who are you?"; authorization answers "what can you do?". Authentication always comes first.
- Use 401 when the user is not signed in, and 403 when they are signed in but lack permission.
- Call
UseAuthentication()beforeUseAuthorization(), and both before your endpoints. - A user's identity is a set of claims — small facts your rules read to make decisions.
- Use cookies for web apps and Blazor, JWT for APIs, and OIDC for "log in with Google/Microsoft".
- For JWT, validate the issuer, audience, lifetime, and signing key, and keep tokens short-lived.
- Authorization grows from simple
[Authorize], to roles, to flexible policies with requirements and handlers. - Set a fallback policy so every endpoint is protected by default and you open public ones on purpose.
- Always use HTTPS, hash passwords with Identity, keep secrets out of code, and use refresh tokens.
- .NET 10 adds built-in passkeys and better OIDC guidance for Blazor.
References and further reading
- Overview of ASP.NET Core Authentication — Microsoft Learn
- Introduction to authorization in ASP.NET Core — Microsoft Learn
- Configure JWT bearer authentication in ASP.NET Core — Microsoft Learn
- Role-based authorization in ASP.NET Core — Microsoft Learn
- Authentication and authorization in Minimal APIs — Microsoft Learn
- JWT Validation and Authorization in ASP.NET Core — .NET Blog
- .NET 10: What's New for Authentication and Authorization — Auth0
Related Posts
API Key Authentication in ASP.NET Core: The Secure Way
Learn how to add API key authentication to your ASP.NET Core API the right way. Use an AuthenticationHandler, hash keys, compare safely, and follow 2026 security best practices, with diagrams and code.
Refresh Tokens and Token Revocation in ASP.NET Core: A Beginner Guide
Learn refresh tokens and token revocation in ASP.NET Core with simple words, diagrams, and code. Short access tokens, rotation, reuse detection, and safe logout.
Rate Limiting in ASP.NET Core: A Simple, Complete Guide
Learn rate limiting in ASP.NET Core with simple examples. Understand fixed window, sliding window, token bucket, and concurrency limiters, with diagrams, code, and real-world advice on which to pick.
Building Secure APIs with Role-Based Access Control in ASP.NET Core
Learn role-based access control (RBAC) in ASP.NET Core. Add roles to JWT tokens, guard endpoints with policies, and return correct 401 and 403 codes, with diagrams and code.
Top 15 Mistakes Developers Make When Creating Web APIs
A warm, beginner-friendly tour of the 15 most common Web API mistakes in ASP.NET Core, with simple fixes, diagrams, tables, and clear C# examples.
Integrate Keycloak with ASP.NET Core Using OAuth 2.0
A beginner-friendly guide to securing an ASP.NET Core API and web app with Keycloak using OAuth 2.0 and OpenID Connect, with diagrams, tables, and copy-paste code.