Skip to content

.NET Core · Security

Authentication in ASP.NET Core

7 min read Updated 2026-06-23 Share:

Practice Authentication interview questions

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).

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:

  1. Calls IAuthenticationService.AuthenticateAsync(defaultScheme).
  2. The handler reads the credential (decrypts cookie, validates JWT).
  3. On success, sets HttpContext.User = ticket.Principal.
  4. On failure/absence, sets HttpContext.User to an anonymous ClaimsPrincipal.

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.

SituationResultWhat happens
Request is anonymous and hits [Authorize]401 ChallengeCookie → redirect to login; JWT → WWW-Authenticate header
Authenticated user lacks required role403 ForbidCookie → 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.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel