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 beforeMapControllers/MapEndpointsand afterUseRouting. - 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
ClaimsPrincipalis the truth. After authentication,HttpContext.Useris aClaimsPrincipalwith one or moreClaimsIdentitys. 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")].
Cookie authentication (server-rendered)
// 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 = Always — HTTPS only. - SameSite = Lax (default) or Strict — CSRF 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)
❌ Wrong: cookie without SameSite/Secure
✅ 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
ClaimsPrincipalafter 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-service → JWT 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
UseRoutingand beforeUseAuthorizationand endpoints. - ⚠️ Mixing Bearer + Cookie defaults — set explicit defaults; use per-endpoint
AuthenticationSchemes. - ⚠️ JWT in localStorage — XSS 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.