Skip to content

Authentication Fundamentals

Key Points

  • Authentication = "who are you?" Authorization = "what can you do?" Different concerns; different middleware.
  • ASP.NET Core uses an authentication scheme abstraction: Cookie, JwtBearer, OpenIdConnect, Negotiate (Windows), Certificate. Multiple schemes can coexist; one is the default.
  • Cookie auth for server-rendered apps (MVC, Razor Pages, Blazor Server). JWT bearer for APIs consumed by SPAs/mobile/services. OIDC for "log in with X" — produces cookies or tokens.
  • Order matters: UseAuthentication()UseAuthorization() → endpoints. Both before MapControllers/MapEndpoints and after UseRouting.
  • Don't store passwords yourself unless you have to. Use ASP.NET Core Identity (or a hosted IdP) — it bundles password hashing (PBKDF2/Argon2id with key-stretching), lockout, 2FA, email confirmation.
  • The ClaimsPrincipal is the truth. After authentication, HttpContext.User is a ClaimsPrincipal with one or more ClaimsIdentitys. Authorization reads claims.

Concepts (deep dive)

The pipeline

Request → Routing → Authentication → Authorization → Endpoint
                          ↓                ↓
                  HttpContext.User  policy/role/claim check

UseAuthentication() reads credentials (cookie, bearer header, etc.) and populates HttpContext.User. UseAuthorization() checks policies against that user. Endpoints can also opt into auth via [Authorize] or RequireAuthorization().

Authentication scheme

builder.Services.AddAuthentication("Cookies")          // default scheme
    .AddCookie("Cookies", o =>
    {
        o.LoginPath = "/login";
        o.AccessDeniedPath = "/forbidden";
        o.ExpireTimeSpan = TimeSpan.FromHours(8);
        o.SlidingExpiration = true;
    })
    .AddJwtBearer("Bearer", o =>                        // additional scheme for API
    {
        o.Authority = "https://login.contoso.com";
        o.Audience = "api://my-api";
        o.TokenValidationParameters.ValidateIssuer = true;
    });

Multiple schemes coexist. Endpoints can opt into a specific one: [Authorize(AuthenticationSchemes = "Bearer")].

// Sign in
var claims = new[]
{
    new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
    new Claim(ClaimTypes.Name, user.Email),
    new Claim(ClaimTypes.Role, "Admin")
};
var identity = new ClaimsIdentity(claims, "Cookies");
var principal = new ClaimsPrincipal(identity);

await HttpContext.SignInAsync("Cookies", principal,
    new AuthenticationProperties { IsPersistent = true });

// Sign out
await HttpContext.SignOutAsync("Cookies");

The cookie is encrypted with the Data Protection API (DPAPI), not just signed. Tampering invalidates it.

Cookie hardening: - HttpOnly = true (default in .NET) — JS can't read it; mitigates XSS theft. - Secure = AlwaysHTTPS only. - SameSite = Lax (default) or StrictCSRF mitigation. None only for cross-site OAuth flows. - Short ExpireTimeSpan for sensitive apps; sliding for UX.

JWT bearer (API)

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Authority = "https://login.microsoftonline.com/{tenant}/v2.0";
        o.Audience = "api://my-api-id";
        o.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ClockSkew = TimeSpan.FromMinutes(2)   // tighter than 5-minute default
        };
    });

Client sends Authorization: Bearer eyJ.... Middleware validates signature (against JWKS at Authority/.well-known/openid-configuration), audience, issuer, lifetime, and populates User.

Pitfalls covered in JWT Validation Pitfalls.

OpenID Connect (OIDC)

For "log in with Google/Microsoft/Okta/Auth0/etc." OIDC layers identity on top of OAuth2:

builder.Services.AddAuthentication(o =>
{
    o.DefaultScheme = "Cookies";
    o.DefaultChallengeScheme = "oidc";
})
    .AddCookie("Cookies")
    .AddOpenIdConnect("oidc", o =>
    {
        o.Authority = "https://login.microsoftonline.com/{tenant}/v2.0";
        o.ClientId = "...";
        o.ClientSecret = "...";    // only for confidential clients
        o.ResponseType = "code";   // auth code flow
        o.UsePkce = true;          // mandatory for SPAs; recommended everywhere
        o.SaveTokens = true;       // store id/access tokens in cookie
        o.Scope.Add("openid");
        o.Scope.Add("profile");
        o.Scope.Add("email");
        o.GetClaimsFromUserInfoEndpoint = true;
    });

Flow: user clicks "Login" → redirected to IdP → authenticates → IdP redirects back with auth code → ASP.NET exchanges code for tokens → creates cookie session.

ClaimsPrincipal & ClaimsIdentity

public class MyController : Controller
{
    public IActionResult Profile()
    {
        var user = User;                              // ClaimsPrincipal
        var id = user.FindFirstValue(ClaimTypes.NameIdentifier);
        var email = user.FindFirstValue(ClaimTypes.Email);
        var roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value);
        var isAdmin = user.IsInRole("Admin");
        var isAuthed = user.Identity?.IsAuthenticated ?? false;
        return Ok(new { id, email, roles, isAdmin });
    }
}

A ClaimsPrincipal can hold multiple ClaimsIdentity objects (e.g., one from cookie, one from API key). The "primary" identity is the first one.

ASP.NET Core Identity

The full UI/data layer for self-managed user accounts:

builder.Services.AddDbContext<AppDb>(o => o.UseSqlServer(...));
builder.Services
    .AddIdentityCore<AppUser>(o =>
    {
        o.Password.RequiredLength = 12;
        o.Password.RequireNonAlphanumeric = true;
        o.Lockout.MaxFailedAccessAttempts = 5;
        o.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
        o.SignIn.RequireConfirmedEmail = true;
    })
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<AppDb>()
    .AddDefaultTokenProviders();

Identity gives you: UserManager<T>, SignInManager<T>, RoleManager<T>. Stores users with PBKDF2-hashed passwords. Includes 2FA, email confirmation, lockout. Use this for self-managed identity in 2026.

For new projects, prefer a hosted IdP (Microsoft Entra, Auth0, Okta, Duende IdentityServer) over self-hosting Identity — less operational burden, better security defaults.

Multi-scheme dispatch

[Authorize(AuthenticationSchemes = "Cookies,Bearer")]   // either accepted
public class ApiController : Controller { /* ... */ }

For mixed apps (server-rendered + API in one process), accept both. The first scheme that succeeds wins.

IAuthenticationService programmatic use

public class LoginEndpoint(SignInManager<AppUser> sm)
{
    public async Task<IResult> Handle(LoginRequest req)
    {
        var result = await sm.PasswordSignInAsync(
            req.Email, req.Password, req.RememberMe, lockoutOnFailure: true);
        return result.Succeeded ? Results.Ok() : Results.Unauthorized();
    }
}

SignInManager wraps HttpContext.SignInAsync plus password verification + lockout.


Code: correct vs wrong

❌ Wrong: storing plaintext or weakly-hashed passwords

user.Password = req.Password;                   // plaintext
user.Password = MD5(req.Password);              // broken hash
user.Password = SHA256(req.Password);           // not key-stretched

✅ Correct: ASP.NET Core Identity (PBKDF2 by default; Argon2id via plugin)

await userManager.CreateAsync(new AppUser { Email = req.Email }, req.Password);
Response.Cookies.Append("session", token);   // defaults vary; insecure on HTTP

✅ Correct: full hardening

Response.Cookies.Append("session", token, new CookieOptions
{
    HttpOnly = true,
    Secure = true,
    SameSite = SameSiteMode.Lax,
    Expires = DateTimeOffset.UtcNow.AddHours(8),
    Path = "/"
});

❌ Wrong: returning JWT to a browser SPA in localStorage

LocalStorage is readable by any script; XSS = stolen token. Use HttpOnly cookies with the BFF pattern instead.


Design patterns for this topic

Pattern 1 — "Cookies for browsers; JWT for services"

  • Intent: match auth medium to client.
  • Rationale: browsers should never see raw bearer tokens.

Pattern 2 — "BFF (Backend-for-Frontend)"

  • Intent: SPA talks to its own .NET backend via cookies; backend holds OAuth tokens server-side.

Pattern 3 — "Hosted IdP over self-managed identity"

  • Intent: delegate auth to Entra/Auth0/Okta/Duende.

Pattern 4 — "Two schemes; per-endpoint selection"

  • Intent: mixed app supports both cookie (UI) and bearer (API).

Pattern 5 — "Claims transformation"

  • Intent: enrich ClaimsPrincipal after authentication (load roles/permissions from DB).
  • Implement IClaimsTransformation; runs per request.

Pros & cons / trade-offs

Approach Pros Cons
Cookie XSRF protected (with anti-forgery); browser-friendly Same-origin only; CSRF concerns
JWT Bearer Stateless; cross-origin OK Hard to revoke; XSS risk if in localStorage
OIDC + Cookie Delegated identity; secure Extra hop to IdP
Self-Identity Full control Operational burden; security responsibility
Hosted IdP Pro-grade security; SSO Cost; vendor coupling

When to use / when to avoid

  • Server-rendered web app → cookies.
  • SPA + API → cookies via BFF, OR JWT short-lived + refresh in HttpOnly cookie.
  • Mobile / desktop / service-to-serviceJWT bearer with OAuth2 client-credentials or PKCE.
  • Avoid storing JWTs in localStorage for browser apps.
  • Avoid rolling your own crypto/password storage — use Identity or hosted IdP.

Interview Q&A

Q1. Authentication vs authorization? AuthN: who you are. AuthZ: what you can do. Order: AuthN runs first, populates HttpContext.User; AuthZ checks policies against it.

Q2. Why is cookie auth typically safer than JWT-in-localStorage for browsers? HttpOnly cookies aren't readable by JS, so XSS can't exfiltrate them. localStorage is wide-open to any script.

Q3. What's a ClaimsPrincipal? The .NET representation of the authenticated user. Holds one or more ClaimsIdentitys, each with Claims. HttpContext.User is a ClaimsPrincipal.

Q4. What does UseAuthentication() actually do? Registers middleware that, on each request, runs the default scheme's handler — reads cookie/header, validates, sets HttpContext.User. No matching credentials → anonymous principal.

Q5. When use OIDC vs raw OAuth2? OIDC adds an id_token for identity. OAuth2 alone is delegation/access. For "log in with X", always OIDC.

Q6. Why PKCE for SPAs? SPAs can't keep a client secret. PKCE substitutes a one-time challenge to bind the auth code to the original client.

Q7. How would you implement role-based authorization? Add ClaimTypes.Role claims; use [Authorize(Roles = "Admin")] or policies via RequireRole. For richer needs, claims-based or policy-based authorization.

Q8. What's claims transformation? After authentication, before authorization, enrich the principal — e.g., load permissions from DB. Implement IClaimsTransformation.

Q9. How do you support multiple schemes? Register multiple .AddXxx; set defaults; opt-in per endpoint via [Authorize(AuthenticationSchemes = "...")].

Q10. ASP.NET Core Identity vs hosted IdP — when each? Identity for tight-coupling to your DB and full self-hosting. Hosted IdP for security maturity, SSO, MFA out of the box, lower operational cost.

Q11. How are cookies protected from tampering? DPAPI (Data Protection API) — encrypts and authenticates them with rotating keys.

Q12. What's a refresh token? Long-lived token used to obtain new short-lived access tokens without re-authenticating. Stored server-side or in HttpOnly cookie.


Gotchas / common mistakes

  • ⚠️ Wrong middleware order — auth must come after UseRouting and before UseAuthorization and endpoints.
  • ⚠️ Mixing Bearer + Cookie defaults — set explicit defaults; use per-endpoint AuthenticationSchemes.
  • ⚠️ JWT in localStorageXSS risk.
  • ⚠️ Long cookie lifetimes without sliding expiration — silent compromise window.
  • ⚠️ No HTTPS in dev — cookies marked Secure won't flow.
  • ⚠️ Loading roles into JWT — token bloat; stale on role change. Use claims transformation.

Further reading