Why authentication knowledge matters in .NET interviews
Authentication is one of the first things that goes wrong in production systems, and interviewers know it. They probe not just "how do you add auth?" but whether you understand the pipeline order, the difference between challenge and forbid, how claims are populated, and when to reach for a custom handler. This article walks through the fundamentals end to end.
Authentication vs authorization — the critical distinction
These two words are often confused, but ASP.NET Core treats them as completely separate concerns with separate middleware.
Authentication answers "who are you?" — it reads a credential (cookie, token, API key) and
establishes the caller's identity as a ClaimsPrincipal.
Authorization answers "what are you allowed to do?" — it evaluates the established identity against roles, policies, and requirements.
// Correct pipeline order — authentication must populate User before authorization reads it:
app.UseRouting();
app.UseAuthentication(); // populate HttpContext.User
app.UseAuthorization(); // check permissions against that User
// Swapped order — every request appears anonymous to the authorization check:
// app.UseAuthorization();
// app.UseAuthentication();
Anonymous user hitting [Authorize] produces 401 Challenge (not logged in).
Authenticated user lacking permission produces 403 Forbid (logged in but not allowed).
How cookie authentication works
Cookie authentication is the simplest and most common scheme for server-rendered apps and
same-origin SPAs. The sign-in handler serializes the ClaimsPrincipal into an encrypted,
tamper-proof cookie. On every subsequent request, the middleware decrypts it and restores
HttpContext.User — no database query per request.
// Register:
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/account/login"; // 401 → redirect here
options.AccessDeniedPath = "/account/denied"; // 403 → redirect here
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true; // reset on each request
});
// Sign in — after validating credentials:
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Role, user.Role),
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties { IsPersistent = dto.RememberMe });
// Sign out:
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
The cookie is encrypted with ASP.NET Core Data Protection. In production, configure a shared, persistent key store (file system, Redis, Azure Blob) — otherwise each restart or scale-out node generates new keys and invalidates all existing sessions.
Claims-based identity — the data model
Every authenticated user is represented as a ClaimsPrincipal containing one or more
ClaimsIdentity objects, each holding a flat list of Claim (type, value, issuer) triples.
// Reading claims — always use FindFirstValue for nullable access:
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); // null if not authenticated
var email = User.FindFirstValue(ClaimTypes.Email);
var roles = User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList();
// Boolean helpers:
bool isAuth = User.Identity?.IsAuthenticated ?? false;
bool isAdmin = User.IsInRole("Admin"); // checks ClaimTypes.Role claims
string? name = User.Identity?.Name; // value of ClaimTypes.Name claim
// Custom claims — any string key is valid:
var tenantId = User.FindFirstValue("tenant_id");
Never store passwords, SSNs, or payment information in claims. Claims live in the cookie or token where any party can decode (though not forge) them.
Authentication schemes — using multiple at once
A scheme is a named registration of an authentication handler. Multiple schemes coexist; the
default scheme is used when no scheme is specified on [Authorize].
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddCookie()
.AddJwtBearer(options => { options.Authority = "https://auth.example.com"; options.Audience = "api1"; })
.AddGoogle(options =>
{
options.ClientId = config["Auth:Google:ClientId"]!;
options.ClientSecret = config["Auth:Google:ClientSecret"]!;
});
// Target a specific scheme per endpoint:
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public class ApiController : ControllerBase { }
How HttpContext.User is populated
UseAuthentication middleware fires early in every request:
- Calls
IAuthenticationService.AuthenticateAsync(defaultScheme). - The handler reads the credential (decrypts cookie, validates JWT).
- On success, sets
HttpContext.User = ticket.Principal. - On failure/absence, sets
HttpContext.Userto an anonymousClaimsPrincipal.
HttpContext.User is never null — check IsAuthenticated before reading claims. Access
the user outside HTTP context via IHttpContextAccessor (register with
builder.Services.AddHttpContextAccessor()), but avoid this in domain services — pass
the user ID as a parameter instead.
SignInAsync, SignOutAsync, and AuthenticateAsync
These are the three operations the authentication system exposes:
// SignInAsync — persist the principal (write cookie, issue token, etc.):
await HttpContext.SignInAsync(scheme, principal, properties);
// SignOutAsync — remove the persistence (clear cookie, revoke session):
await HttpContext.SignOutAsync(scheme);
// AuthenticateAsync — read/validate the current credential (called by middleware):
var result = await HttpContext.AuthenticateAsync();
if (result.Succeeded) { var user = result.Principal; }
// ChallengeAsync — trigger 401 (cookie → redirect to login; JWT → WWW-Authenticate header):
await HttpContext.ChallengeAsync();
// ForbidAsync — trigger 403 (cookie → redirect to access denied; JWT → 403 body):
await HttpContext.ForbidAsync();
For JWT-based APIs, SignInAsync is not used — you generate and return the token yourself
in the login response. The middleware only calls AuthenticateAsync (to validate the token
on incoming requests), never SignInAsync.
Challenge vs Forbid — 401 vs 403
This distinction trips up many candidates.
| Situation | Result | What happens |
|---|---|---|
Request is anonymous and hits [Authorize] | 401 Challenge | Cookie → redirect to login; JWT → WWW-Authenticate header |
| Authenticated user lacks required role | 403 Forbid | Cookie → redirect to access denied; JWT → 403 body |
// Manual trigger in a controller:
public IActionResult GetAdminData()
{
if (!User.Identity!.IsAuthenticated) return Challenge(); // 401
if (!User.IsInRole("Admin")) return Forbid(); // 403
return Ok(_adminService.GetData());
}
For cookie-auth APIs (returning JSON, not HTML), override the default redirect behavior:
.AddCookie(options =>
{
options.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = 401; return Task.CompletedTask; };
options.Events.OnRedirectToAccessDenied = ctx => { ctx.Response.StatusCode = 403; return Task.CompletedTask; };
});
Custom authentication handlers
When built-in schemes don't fit (API key, HMAC signature, custom SSO), implement
AuthenticationHandler<TOptions>:
public class ApiKeyAuthHandler : AuthenticationHandler<ApiKeyAuthOptions>
{
private readonly IApiKeyStore _store;
public ApiKeyAuthHandler(
IOptionsMonitor<ApiKeyAuthOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IApiKeyStore store)
: base(options, logger, encoder)
=> _store = store;
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(Options.HeaderName, out var keyValues))
return AuthenticateResult.NoResult(); // scheme doesn't apply to this request
var client = await _store.FindByKeyAsync(keyValues.FirstOrDefault());
if (client is null)
return AuthenticateResult.Fail("Invalid API key");
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, client.ClientId) };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}
builder.Services
.AddAuthentication()
.AddScheme<ApiKeyAuthOptions, ApiKeyAuthHandler>("ApiKey", _ => { });
NoResult() means "this scheme doesn't apply" — the framework tries the next scheme.
Fail() means "this scheme applied but the credential was invalid" — authentication fails immediately.
External OAuth providers
For Google, Microsoft, or generic OIDC, pair an OAuth scheme with a local cookie scheme:
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie()
.AddGoogle(options =>
{
options.ClientId = config["Auth:Google:ClientId"]!;
options.ClientSecret = config["Auth:Google:ClientSecret"]!;
options.Scope.Add("profile");
options.Scope.Add("email");
});
// Trigger the OAuth redirect:
[HttpGet("login/google")]
public IActionResult LoginGoogle()
=> Challenge(new AuthenticationProperties { RedirectUri = "/" }, "Google");
The OAuth scheme handles the redirect and token exchange; the cookie scheme persists the resulting identity. Without the cookie scheme, every request would re-trigger the OAuth flow.
Data Protection and key management
Cookie encryption relies on ASP.NET Core Data Protection. For production (multiple machines or restarts), configure a shared persistent key store:
// File system (shared NFS/EFS path):
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/keys"))
.SetApplicationName("MyApp");
// Redis (containerized workloads):
builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys");
Without shared keys, a new app instance can't decrypt cookies set by another instance — users get silently logged out on every deploy.
Minimal API authentication
// Single endpoint:
app.MapGet("/profile", (ClaimsPrincipal user) =>
Results.Ok(new { Name = user.Identity!.Name })
).RequireAuthorization();
// Entire group:
var api = app.MapGroup("/api").RequireAuthorization();
api.MapGet("/orders", (IOrderService svc) => svc.GetAll());
api.MapGet("/health", () => Results.Ok()).AllowAnonymous(); // explicit exception
Recap
Authentication in ASP.NET Core is a pipeline: UseAuthentication populates HttpContext.User
from the credential, then UseAuthorization decides what that user can do. Cookie authentication
is the default for web apps; JWT bearer is standard for APIs. Claims carry identity data as
key-value pairs — keep them minimal and non-sensitive. Use Challenge for anonymous requests
(401) and Forbid for authenticated but unauthorized ones (403). For custom protocols, implement
AuthenticationHandler<TOptions>. In production, configure a shared Data Protection key store to
survive restarts and scale-out.