Overview
Authentication and authorization are two core security concepts in C# web applications, especially in ASP.NET Core APIs and web apps.
Authentication answers the question: Who are you?
Authorization answers the question: What are you allowed to do?
Authentication verifies the identity of a user, service, or client application. Authorization decides whether that authenticated identity has permission to access a resource or perform an operation.
For example, when a user signs in with a username and password, cookie, JWT bearer token, OpenID Connect provider, or external identity provider, the application is performing authentication. When the same user tries to access an admin endpoint, update another user's data, approve a payment, or call a protected API, the application performs authorization.
This topic matters because almost every production C# web application needs secure identity and access control. A developer must understand the difference between proving identity and granting access. Confusing the two can cause serious security issues, such as allowing authenticated users to access data they should not see.
In ASP.NET Core, authentication is handled by authentication middleware, authentication schemes, and authentication handlers. Authorization is handled by [Authorize], policies, roles, claims, requirements, handlers, and authorization middleware.
This topic is important for interviews because it tests practical web security understanding. Interviewers often ask about:
- The difference between authentication and authorization.
- What
[Authorize]actually does. - How JWT bearer authentication works.
- What claims, roles, and policies are.
- Why middleware order matters.
- The difference between
401 Unauthorizedand403 Forbidden. - How to secure APIs using cookies, JWTs, or external identity providers.
- How to avoid common security mistakes in ASP.NET Core.
A strong answer should explain both concepts clearly and show how they work together in a real ASP.NET Core application.
Core Concepts
Authentication
Authentication is the process of verifying an identity.
In an ASP.NET Core application, authentication usually produces a ClaimsPrincipal that represents the current user or caller.
A ClaimsPrincipal contains one or more identities, and each identity contains claims.
Example claims:
sub: 12345
name: Minh
email: [email protected]
role: Admin
tenant_id: tenant-001
permission: orders.read
Authentication does not automatically mean the user can access everything. It only means the application has identified who the caller is.
Common authentication mechanisms in C# and ASP.NET Core include:
- Cookie authentication.
- JWT bearer authentication.
- OpenID Connect authentication.
- OAuth 2.0 access tokens.
- ASP.NET Core Identity.
- Windows authentication.
- API key authentication through custom middleware or handlers.
- External identity providers such as Microsoft Entra ID, Auth0, Okta, or Google.
Example JWT bearer authentication registration:
using Microsoft.AspNetCore.Authentication.JwtBearer;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "orders-api";
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
In this example, authentication validates the incoming bearer token and builds the authenticated user identity.
Authorization
Authorization is the process of deciding whether an authenticated identity can access a resource or perform an action.
Authorization usually depends on information from authentication, such as:
- User ID.
- Roles.
- Claims.
- Permissions.
- Tenant ID.
- Department.
- Subscription level.
- Resource ownership.
Example authorization using [Authorize]:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/orders")]
[Authorize]
public class OrdersController : ControllerBase
{
[HttpGet]
public IActionResult GetOrders()
{
return Ok(new[] { "Order 1", "Order 2" });
}
}
This requires the caller to be authenticated. If the caller is not authenticated, the request is rejected.
Authorization can also be more specific:
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
public IActionResult DeleteOrder(int id)
{
return NoContent();
}
This requires the authenticated user to be in the Admin role.
Authentication vs Authorization
Authentication and authorization are related but different.
A user can be authenticated but not authorized.
Example:
- Minh signs in successfully.
- Minh is authenticated.
- Minh tries to access
/api/admin/users. - Minh is not an admin.
- The application returns
403 Forbidden.
A request can also be unauthenticated.
Example:
- No token or cookie is sent.
- The application cannot identify the caller.
- The application returns
401 Unauthorized.
The key point is that authentication comes first, and authorization uses the authenticated identity to make access decisions.
ClaimsPrincipal, ClaimsIdentity, and Claims
ASP.NET Core represents the current user using HttpContext.User, which is a ClaimsPrincipal.
Example:
[Authorize]
[HttpGet("me")]
public IActionResult GetCurrentUser()
{
var userId = User.FindFirst("sub")?.Value;
var email = User.FindFirst("email")?.Value;
var roles = User.Claims
.Where(c => c.Type == "role")
.Select(c => c.Value)
.ToList();
return Ok(new
{
UserId = userId,
Email = email,
Roles = roles
});
}
Important terms:
Claims are not always permissions. A claim only states something about the user or token. The application must decide how to interpret it.
For example, a claim department: finance does not automatically grant access. You need authorization rules that use that claim.
Authentication Schemes
An authentication scheme is a named authentication configuration. It tells ASP.NET Core which handler should authenticate a request.
Common schemes include:
CookiesBearer- OpenID Connect schemes
- Custom schemes
Example using cookies:
using Microsoft.AspNetCore.Authentication.Cookies;
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.AccessDeniedPath = "/access-denied";
});
Example using JWT bearer:
using Microsoft.AspNetCore.Authentication.JwtBearer;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "orders-api";
});
If an app has one authentication scheme, it is often configured as the default scheme. If an app has multiple schemes, you may need to specify which one to use.
Example:
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[HttpGet("api-data")]
public IActionResult GetApiData()
{
return Ok();
}
Multiple schemes are common when the same app supports both browser cookie authentication and API bearer token authentication.
Authentication Middleware and Authorization Middleware
ASP.NET Core uses middleware to process requests.
The common order is:
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
The order matters.
UseAuthentication() reads the incoming credential, such as a cookie or bearer token, validates it, and sets HttpContext.User.
UseAuthorization() checks endpoint authorization requirements, such as [Authorize], roles, or policies.
If UseAuthorization() runs before UseAuthentication(), authorization may not see the authenticated user correctly.
Best practice:
app.UseAuthentication();
app.UseAuthorization();
Authentication should run before authorization.
[Authorize] and [AllowAnonymous]
[Authorize] requires authorization for a controller, action, Razor Page, or endpoint.
Example:
[Authorize]
[ApiController]
[Route("api/profile")]
public class ProfileController : ControllerBase
{
[HttpGet]
public IActionResult GetProfile()
{
return Ok();
}
}
[AllowAnonymous] allows anonymous access even when a controller or global policy requires authentication.
Example:
[Authorize]
[ApiController]
[Route("api/account")]
public class AccountController : ControllerBase
{
[AllowAnonymous]
[HttpPost("login")]
public IActionResult Login()
{
return Ok();
}
[HttpGet("me")]
public IActionResult Me()
{
return Ok();
}
}
In this example:
/api/account/loginallows anonymous access./api/account/merequires an authenticated user.
Common mistake:
[Authorize]
public class AccountController : ControllerBase
{
[HttpPost("login")]
public IActionResult Login()
{
return Ok();
}
}
If you forget [AllowAnonymous], the login endpoint may require authentication, which makes no sense.
Role-Based Authorization
Role-based authorization grants access based on roles.
Example:
[Authorize(Roles = "Admin")]
[HttpGet("admin-report")]
public IActionResult GetAdminReport()
{
return Ok();
}
Multiple roles can be allowed:
[Authorize(Roles = "Admin,Manager")]
[HttpGet("management-report")]
public IActionResult GetManagementReport()
{
return Ok();
}
This means the user must be in either the Admin role or the Manager role.
Role-based authorization is simple and useful for broad access control. However, it can become hard to maintain when permissions become fine-grained.
For example, this is less flexible:
[Authorize(Roles = "Admin")]
This may be better for larger systems:
[Authorize(Policy = "CanApproveOrders")]
The policy name describes the permission rather than a role name.
Claims-Based Authorization
Claims-based authorization uses claims to make access decisions.
Example policy:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("FinanceOnly", policy =>
{
policy.RequireClaim("department", "finance");
});
});
Controller action:
[Authorize(Policy = "FinanceOnly")]
[HttpGet("finance-report")]
public IActionResult GetFinanceReport()
{
return Ok();
}
This requires the user to have a department claim with value finance.
Claims-based authorization is more flexible than simple role checks because it can use identity information such as:
- Department.
- Tenant.
- Region.
- Permission.
- Subscription level.
- Employment type.
- Security clearance.
However, claims must be trusted. Claims from a JWT should come from a trusted identity provider and must be validated.
Policy-Based Authorization
Policy-based authorization is the recommended approach for complex authorization rules in ASP.NET Core.
A policy is a named rule.
Example:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanDeleteOrders", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireRole("Admin");
policy.RequireClaim("permission", "orders.delete");
});
});
Usage:
[Authorize(Policy = "CanDeleteOrders")]
[HttpDelete("{id:int}")]
public IActionResult DeleteOrder(int id)
{
return NoContent();
}
Policy-based authorization is useful because it:
- Centralizes authorization rules.
- Improves readability.
- Avoids repeating role and claim logic across actions.
- Supports custom requirements and handlers.
- Works well for enterprise applications.
Custom Authorization Requirements and Handlers
For complex authorization, you can create custom requirements and handlers.
Example requirement:
using Microsoft.AspNetCore.Authorization;
public sealed class MinimumAgeRequirement : IAuthorizationRequirement
{
public MinimumAgeRequirement(int minimumAge)
{
MinimumAge = minimumAge;
}
public int MinimumAge { get; }
}
Example handler:
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
public sealed class MinimumAgeHandler
: AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumAgeRequirement requirement)
{
var dateOfBirthValue = context.User.FindFirst("date_of_birth")?.Value;
if (!DateTime.TryParse(dateOfBirthValue, out var dateOfBirth))
{
return Task.CompletedTask;
}
var age = DateTime.Today.Year - dateOfBirth.Year;
if (dateOfBirth.Date > DateTime.Today.AddYears(-age))
{
age--;
}
if (age >= requirement.MinimumAge)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Register the handler and policy:
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AtLeast18", policy =>
{
policy.Requirements.Add(new MinimumAgeRequirement(18));
});
});
Use the policy:
[Authorize(Policy = "AtLeast18")]
[HttpGet("restricted-content")]
public IActionResult GetRestrictedContent()
{
return Ok();
}
This is useful when authorization logic cannot be expressed with simple role or claim checks.
Resource-Based Authorization
Sometimes authorization depends on the specific resource being accessed.
Example:
- A user can edit their own order.
- An admin can edit any order.
- A manager can approve only orders from their department.
- A tenant user can access only resources from their tenant.
Attribute-based authorization may not have enough context because the resource must be loaded first.
Example:
[Authorize]
[HttpPut("{id:int}")]
public async Task<IActionResult> UpdateOrder(
int id,
UpdateOrderRequest request,
IAuthorizationService authorizationService,
CancellationToken cancellationToken)
{
var order = await _orderRepository.GetByIdAsync(id, cancellationToken);
if (order is null)
{
return NotFound();
}
var authorizationResult = await authorizationService.AuthorizeAsync(
User,
order,
"CanUpdateOrder");
if (!authorizationResult.Succeeded)
{
return Forbid();
}
order.Update(request.Description);
await _orderRepository.SaveChangesAsync(cancellationToken);
return NoContent();
}
Resource-based authorization is more secure than checking only whether the user is authenticated.
A common security mistake is checking only this:
[Authorize]
public async Task<IActionResult> GetOrder(int id)
{
var order = await _orderRepository.GetByIdAsync(id);
return Ok(order);
}
This verifies that the user is logged in but does not verify that the user owns or is allowed to access the order.
JWT Bearer Authentication
JWT bearer authentication is common for APIs.
The client sends an access token in the Authorization header:
Authorization: Bearer eyJhbGciOi...
ASP.NET Core validates the token and creates a ClaimsPrincipal.
A secure API should validate:
- Token signature.
- Issuer.
- Audience.
- Expiration.
- Relevant claims.
- Token type and intended use.
Example:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://login.example.com";
options.Audience = "orders-api";
options.TokenValidationParameters.ValidateIssuer = true;
options.TokenValidationParameters.ValidateAudience = true;
options.TokenValidationParameters.ValidateLifetime = true;
});
Common mistake:
// Bad idea for production
options.TokenValidationParameters.ValidateIssuerSigningKey = false;
Do not weaken token validation in production.
JWTs are commonly used for stateless API authentication, mobile apps, SPAs, and service-to-service calls. However, token storage and refresh flows must be designed carefully.
Cookie Authentication
Cookie authentication is common for server-rendered web apps and some browser-based applications.
After sign-in, the server creates an authentication cookie. The browser sends the cookie with later requests.
Example registration:
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "__Host-AppAuth";
options.LoginPath = "/login";
options.AccessDeniedPath = "/access-denied";
options.SlidingExpiration = true;
});
Cookies are convenient for browser apps because the browser automatically sends them. However, cookie-based authentication must handle:
- CSRF protection.
- Secure cookie settings.
- SameSite behavior.
- Expiration.
- Sign-out.
- Data protection key management across multiple servers.
JWTs and cookies both authenticate users, but they are used differently.
OpenID Connect and OAuth 2.0
OpenID Connect and OAuth 2.0 are related but not the same.
OpenID Connect is about signing in users and getting identity information. It answers authentication questions.
OAuth 2.0 is about delegated access to resources. It is commonly used to obtain access tokens for APIs.
In practical ASP.NET Core development:
- A web app may use OpenID Connect to sign in a user.
- An API may use JWT bearer authentication to validate access tokens.
- Authorization policies decide what the user or client can access.
A common interview mistake is saying OAuth is simply a login protocol. In modern systems, OpenID Connect is typically used for login, while OAuth 2.0 is used for delegated authorization.
401 Unauthorized vs 403 Forbidden
401 Unauthorized means the request is not authenticated or the authentication failed.
Examples:
- No token was provided.
- Token is expired.
- Token signature is invalid.
- Cookie is missing.
- The app cannot identify the caller.
403 Forbidden means the caller is authenticated but not allowed to access the resource.
Examples:
- User is signed in but lacks the required role.
- User has a valid token but missing required permission.
- User tries to access another tenant's data.
- User is authenticated but fails a policy check.
In ASP.NET Core terms:
Challenge()often results in401.Forbid()often results in403.
Example:
if (!User.Identity?.IsAuthenticated ?? true)
{
return Challenge();
}
if (!User.IsInRole("Admin"))
{
return Forbid();
}
In APIs, returning the correct status code helps clients know whether they need to sign in again or show an access denied message.
Challenge vs Forbid
Authentication handlers respond to two important actions:
- Challenge.
- Forbid.
A challenge asks the client to authenticate. This commonly maps to 401 Unauthorized for APIs or redirects to login for cookie-based web apps.
A forbid tells the client that authentication succeeded, but access is denied. This commonly maps to 403 Forbidden for APIs or redirects to an access denied page for cookie-based web apps.
Example:
[Authorize(Roles = "Admin")]
[HttpGet("admin")]
public IActionResult AdminOnly()
{
return Ok();
}
If the user is not signed in, the app challenges the user.
If the user is signed in but not an admin, the app forbids access.
Global Authorization
Some APIs require authentication by default.
You can configure a fallback policy:
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
This means endpoints require authenticated users unless explicitly marked as anonymous.
Example:
app.MapGet("/health", () => Results.Ok("Healthy"))
.AllowAnonymous();
app.MapGet("/profile", () => Results.Ok("Profile"))
.RequireAuthorization();
For controller APIs, you can also use [Authorize] at the controller or base controller level.
A secure default is useful because developers are less likely to accidentally expose endpoints. However, public endpoints like login, registration, health checks, documentation, and webhook callbacks must be considered carefully.
Minimal APIs and Authorization
Minimal APIs use endpoint methods instead of controller attributes, but the same security concepts apply.
Example:
app.MapGet("/api/orders", () =>
{
return Results.Ok(new[] { "Order 1", "Order 2" });
})
.RequireAuthorization();
Policy example:
app.MapDelete("/api/orders/{id:int}", (int id) =>
{
return Results.NoContent();
})
.RequireAuthorization("CanDeleteOrders");
Allow anonymous:
app.MapPost("/api/account/login", () =>
{
return Results.Ok();
})
.AllowAnonymous();
Minimal APIs still use authentication and authorization middleware:
app.UseAuthentication();
app.UseAuthorization();
The syntax is different, but the concepts are the same.
Authentication and Authorization in Clean Architecture
In Clean Architecture, authentication and authorization should be separated from business logic.
Typical responsibilities:
Example:
public interface ICurrentUser
{
string? UserId { get; }
bool IsAuthenticated { get; }
bool HasPermission(string permission);
}
Implementation in the API or infrastructure layer:
public sealed class CurrentUser : ICurrentUser
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CurrentUser(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public string? UserId =>
_httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value;
public bool IsAuthenticated =>
_httpContextAccessor.HttpContext?.User.Identity?.IsAuthenticated == true;
public bool HasPermission(string permission)
{
return _httpContextAccessor.HttpContext?.User
.HasClaim("permission", permission) == true;
}
}
This allows application handlers to access current-user information without depending directly on MVC controller classes.
However, avoid spreading authorization logic randomly across the codebase. Centralize policies and use clear authorization services where possible.
Authentication Is Not Enough for Multi-Tenant Security
In multi-tenant applications, authentication only identifies the caller. It does not automatically protect tenant data.
Bad example:
[Authorize]
[HttpGet("{orderId:int}")]
public async Task<IActionResult> GetOrder(int orderId)
{
var order = await _dbContext.Orders.FindAsync(orderId);
return Ok(order);
}
This endpoint checks that the user is logged in, but it does not verify tenant access.
Better example:
[Authorize]
[HttpGet("{orderId:int}")]
public async Task<IActionResult> GetOrder(int orderId)
{
var tenantId = User.FindFirst("tenant_id")?.Value;
var order = await _dbContext.Orders
.Where(o => o.Id == orderId && o.TenantId == tenantId)
.SingleOrDefaultAsync();
return order is null ? NotFound() : Ok(order);
}
This checks resource access using tenant information.
A good interview answer should mention that authorization often includes row-level or resource-level checks, not just endpoint-level [Authorize].
Common Security Mistakes
Common mistakes include:
- Confusing authentication with authorization.
- Assuming logged-in users can access all data.
- Using
[Authorize]but not checking resource ownership. - Putting sensitive permissions only in frontend code.
- Trusting unvalidated JWTs.
- Disabling issuer, audience, signature, or lifetime validation.
- Using roles for every permission in a large system.
- Forgetting middleware order.
- Forgetting
[AllowAnonymous]on login endpoints. - Returning
401when the user is authenticated but not allowed. - Returning
403when the user is not authenticated. - Storing JWTs insecurely in browser storage without considering XSS risk.
- Not protecting cookie-based flows against CSRF.
- Exposing too much information in error messages.
- Hard-coding authorization rules throughout controllers.
- Not testing authorization paths.
Best Practices
Use authentication to establish identity and authorization to enforce access rules.
Validate tokens fully in production.
Prefer policy-based authorization for complex systems.
Use role-based authorization for simple broad access control.
Use claims and permissions for fine-grained access control.
Use resource-based authorization when access depends on the specific entity being accessed.
Always use UseAuthentication() before UseAuthorization().
Use [AllowAnonymous] intentionally for public endpoints.
Return correct status codes:
401for unauthenticated or invalid authentication.403for authenticated but not allowed.
Do not rely on frontend checks for security. Backend authorization is required.
Keep authentication configuration in infrastructure or startup code, and keep business access decisions explicit and testable.
Add automated tests for:
- Anonymous access.
- Authenticated access.
- Missing roles.
- Missing permissions.
- Cross-tenant access.
- Resource ownership rules.
- Expired or invalid tokens.