Table of Contents
- Course Overview
- Authentication Fundamentals
- Authentication vs Authorization
- Cookie Authentication — Configuration
- Restricting Access with the Authorize Attribute
- Login Page
- Sign In and ClaimsPrincipal
- The Identity Cookie and Accessing Claims
- SameSite Cookies and CSRF Protection
- External Identity Providers (Google)
- Claims Transformation with External Providers
- ASP.NET Core Identity
- Discovering Identity
- Essentials — Program.cs and DbContext
- Customizing the User Interface
- Adding Identity to an Existing Project
- Working with Claims
- UserClaims and Adding Claims on Registration
- Roles
- Token Providers and Email
- Two-Factor Authentication (2FA)
- External Identity Providers with Identity
- Architecture and Advanced Customization
- Multi-Application Authentication with OpenID Connect
- Concepts — Identity Provider
- Client Authentication Process
- Authorization Code Flow and PKCE
- Duende IdentityServer
- Configuring an Identity Provider
- External Providers in IdentityServer
- Extending IdentityServer — Profile Service
- Anatomy of a JWT Token
- Claims Mapping and Token Optimization
- Ready-to-Use Identity Providers
- Authentication for Single-Page Applications (BFF)
- Authorization Policies
- JWT Bearer Authentication (APIs)
- OAuth 2.0 — Client Credentials Flow
- Data Protection API
- Anti-Forgery Tokens and CORS
- Review Questions
- Advanced Authorization Scenarios
- Security Best Practices
- Review Questions — Part 2
1. Course Overview
This course covers the authentication and authorization technologies built into ASP.NET Core:
- Cookie authentication with identity cookies
- ASP.NET Core Identity — complete framework with user management, passwords, 2FA
- OpenID Connect and OAuth — centralized authentication with single sign-on
- Authorization policies — fine-grained control over what users can do
The demo application is Globomantics, a fictional MVC application that manages conferences and talk proposals.
2. Authentication Fundamentals
2.1 Authentication vs Authorization
┌─────────────────────────────────────────────────────────────────┐
│ HOTEL ANALOGY │
│ │
│ AUTHENTICATION AUTHORIZATION │
│ ═══════════════ ═══════════════ │
│ "Who are you?" "What can you do?" │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Passport │ │ Hotel Key Card │ │
│ │ (Claims) │ │ (Limited Access) │ │
│ │ │ │ │ │
│ │ - Name │ │ ✓ My room │ │
│ │ - Address │ │ ✓ The pool │ │
│ │ - Birthdate │ │ ✗ Other rooms │ │
│ └──────────────┘ └──────────────────────┘ │
│ │
│ Receptionist checks ID Key = proof of authentication │
└─────────────────────────────────────────────────────────────────┘
Authentication: verifying the identity of a user (passport → password).
Authorization: controlling what an authenticated user can do.
Claims: information about the user (name, address, date of birth, role, department, etc.). They form the identity and also serve authorization purposes.
Examples of claim-based authorization:
- Claim
role = manager→ can add a customer - Claim
department = HR→ can view salaries
2.2 Cookie Authentication — Configuration
ASP.NET Core offers several authentication methods. The most fundamental is the identity cookie.
ASP.NET Core Middleware Pipeline with Authentication
┌──────────────────────────────────────────────────────────────────┐
│ ASP.NET CORE PIPELINE │
│ │
│ Incoming HTTP Request │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ UseRouting │ ← Determines which endpoint will be called │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │UseAuthentication │ ← IMPORTANT: before endpoints! │
│ │ │ Rebuilds ClaimsPrincipal │
│ │ │ from encrypted cookie │
│ └──────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ UseAuthorization │ ← Checks policies │
│ └──────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ MapControllers │ ← Endpoints (MVC, Razor Pages, etc.) │
│ └──────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
Configuration in Program.cs
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
// 1. Configure authentication
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
// --- Redirect paths ---
options.LoginPath = "/Account/Login"; // Redirect if not authenticated
options.LogoutPath = "/Account/Logout"; // Logout endpoint
options.AccessDeniedPath = "/Account/AccessDenied"; // Redirect if access denied
// --- Authentication ticket lifetime ---
options.ExpireTimeSpan = TimeSpan.FromDays(14); // 14 days (default)
options.SlidingExpiration = true; // Auto-renew if > half time elapsed
// --- Cookie security ---
options.Cookie.HttpOnly = true; // JavaScript cannot read cookie (XSS)
options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // HTTPS only
options.Cookie.SameSite = SameSiteMode.Lax; // Lax or Strict for CSRF
options.Cookie.Name = ".AspNetCore.Identity.Application"; // Custom name
// --- Persistent vs session cookie ---
// Default: session cookie (destroyed when browser closes)
// options.Cookie.MaxAge = TimeSpan.FromDays(14); // Force a persistent cookie
});
// By default, the "Cookies" scheme is used.
// CookieAuthenticationDefaults.AuthenticationScheme = "Cookies"
var app = builder.Build();
app.UseRouting();
// 2. Add authentication middleware to the pipeline
app.UseAuthentication(); // MUST be before UseAuthorization and endpoints
app.UseAuthorization();
app.MapDefaultControllerRoute();
app.Run();
Key CookieAuthenticationOptions properties:
| Property | Default | Description |
|---|---|---|
ExpireTimeSpan | 14 days | Auth ticket lifetime |
SlidingExpiration | true | Renew ticket if > half time elapsed |
Cookie.HttpOnly | true | Protects against XSS |
Cookie.SecurePolicy | SameAsRequest | Always in production |
Cookie.SameSite | Lax | Protects against CSRF |
Cookie.Name | AspNetCore.Cookies | Name visible in browser |
LoginPath | /Account/Login | Redirect if not authenticated |
AccessDeniedPath | /Account/AccessDenied | Redirect if 403 |
2.3 Restricting Access with the Authorize Attribute
// ProposalsController.cs
[Authorize] // Only authenticated users can access
public class ProposalsController : Controller
{
// All actions require authentication
public IActionResult Index() { ... }
[AllowAnonymous] // Exception: accessible without authentication
public IActionResult PublicProposals() { ... }
}
[Authorize] behavior:
- On the controller: all actions are protected
- On an individual action: only that action is protected
- Without
[Authorize]: accessible by everyone (insecure default behavior)
Secure-by-default with a global filter:
// Program.cs — applies [Authorize] to all controllers
builder.Services.AddControllersWithViews(options =>
{
options.Filters.Add(new AuthorizeFilter());
});
// Use [AllowAnonymous] for public controllers/actions
In Razor Pages:
[Authorize]
public class IndexModel : PageModel { ... }
In Blazor:
@attribute [Authorize]
@page "/proposals"
When an unauthenticated user tries to access a protected resource, the Cookie scheme redirects to Account/Login?ReturnUrl=....
2.4 Login Page
// AccountController.cs
public class AccountController : Controller
{
private readonly IUserRepository _userRepository;
public AccountController(IUserRepository userRepository)
{
_userRepository = userRepository;
}
// GET /Account/Login
[AllowAnonymous]
public IActionResult Login(string returnUrl = "/")
{
return View(new LoginModel { ReturnUrl = returnUrl });
}
// POST /Account/Login
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginModel loginModel)
{
// Retrieves user if username + password match
// Password is compared to its stored HASH (never in plain text!)
var user = _userRepository.GetByUsernameAndPassword(
loginModel.Username, loginModel.Password);
if (user == null)
return Unauthorized();
// Sign in the user and redirect
await SignInUser(user, loginModel.RememberLogin);
// LocalRedirect protects against open redirection attacks
return LocalRedirect(loginModel.ReturnUrl);
}
// GET/POST /Account/Logout
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return RedirectToAction("Index", "Conference");
}
}
LoginModel:
public class LoginModel
{
public string Username { get; set; }
public string Password { get; set; }
public bool RememberLogin { get; set; }
public string ReturnUrl { get; set; } = "/";
}
Critical security: always store passwords as hashes (SHA256, bcrypt, etc.), never in plain text. Compare the hash of the entered password with the stored hash.
LocalRedirect vs Redirect: use
LocalRedirectto protect against open redirection attacks (where an attacker redirects to a malicious site after login).
2.5 Sign In and ClaimsPrincipal
┌──────────────────────────────────────────────────────────────────┐
│ CLAIMS STRUCTURE │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ ClaimsPrincipal │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ ClaimsIdentity (Cookie) │ │ │
│ │ │ │ │ │
│ │ │ Claim: NameIdentifier = "user-456" │ │ │
│ │ │ Claim: Name = "Jane Smith" │ │ │
│ │ │ Claim: Role = "organizer" │ │ │
│ │ │ Claim: FavoriteColor = "green" │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ ClaimsIdentity (Google) │ │ │
│ │ │ (if multi-scheme) │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ │ │
│ │ .Claims → all claims from all identities │ │
│ └─────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
// In AccountController.Login (POST)
private async Task SignInUser(User user, bool rememberLogin)
{
// 1. Create the Claims list
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id), // Sub — unique identifier
new Claim(ClaimTypes.Name, user.Username), // Name
new Claim(ClaimTypes.Role, user.Role), // Role
// Custom claim (non-standard)
new Claim("FavoriteColor", user.FavoriteColor)
};
// 2. Create the ClaimsIdentity
var identity = new ClaimsIdentity(
claims,
CookieAuthenticationDefaults.AuthenticationScheme);
// 3. Create the ClaimsPrincipal
var principal = new ClaimsPrincipal(identity);
// 4. Sign in the user (= create the identity cookie)
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties
{
// Persistent cookie if "Remember me" checked
IsPersistent = rememberLogin
});
}
Standard Claim types (ClaimTypes.*):
| Constant | Value (namespace) |
|---|---|
ClaimTypes.NameIdentifier | .../claims/nameidentifier |
ClaimTypes.Name | .../claims/name |
ClaimTypes.Role | .../claims/role |
ClaimTypes.Email | .../claims/emailaddress |
ClaimTypes.DateOfBirth | .../claims/dateofbirth |
2.6 The Identity Cookie and Accessing Claims
How the identity cookie works
┌──────────────────────────────────────────────────────────────────┐
│ IDENTITY COOKIE LIFECYCLE │
│ │
│ 1. Successful login │
│ └─► Claims → ClaimsIdentity → ClaimsPrincipal │
│ └─► ENCRYPTION with Data Protection key │
│ └─► Cookie "AspNetCore.Cookies" → Browser │
│ │
│ 2. Next request │
│ Browser sends cookie automatically (same domain) │
│ └─► Server DECRYPTS with the same key │
│ └─► ClaimsPrincipal automatically rebuilt │
│ └─► Accessible via HttpContext.User │
│ │
│ Note: Data Protection manages keys automatically │
│ (secure storage + periodic rotation) │
└──────────────────────────────────────────────────────────────────┘
The cookie is named AspNetCore.Cookies by default. Its value is encrypted and unreadable in the browser.
Accessing Claims in MVC views
// In a Controller
public IActionResult Index()
{
// User is of type ClaimsPrincipal
var name = User.Identity?.Name; // Value of the Name claim
var isAuthenticated = User.Identity?.IsAuthenticated ?? false;
// Access a specific claim
var role = User.FindFirst(ClaimTypes.Role)?.Value;
var favoriteColor = User.FindFirst("FavoriteColor")?.Value;
// All claims
var allClaims = User.Claims;
return View();
}
@* In a Razor View (Layout.cshtml) *@
@if (User.Identity?.IsAuthenticated == true)
{
<span>Hello, @User.Identity.Name!</span>
<a asp-controller="Account" asp-action="Logout">Logout</a>
@* Display all claims *@
@foreach (var claim in User.Claims)
{
<div>@claim.Type: @claim.Value</div>
}
}
else
{
<a asp-controller="Account" asp-action="Login">Login</a>
}
Accessing Claims outside controllers/views
// Register HttpContextAccessor in Program.cs
builder.Services.AddHttpContextAccessor();
// In any injected service
public class ConferenceRepository : IConferenceRepository
{
private readonly IHttpContextAccessor _httpContextAccessor;
public ConferenceRepository(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public string GetCurrentUserId()
{
return _httpContextAccessor.HttpContext?.User
.FindFirst(ClaimTypes.NameIdentifier)?.Value;
}
}
In Blazor
// In a Blazor component
@inject AuthenticationStateProvider AuthStateProvider
@code {
private string userName;
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
userName = user.Identity?.Name;
}
}
@* In the root App.razor component *@
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
...
</Router>
</CascadingAuthenticationState>
Reacting to cookie events
// Program.cs — react to principal validation (e.g., fired employee)
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = async context =>
{
var userRepo = context.HttpContext.RequestServices
.GetRequiredService<IUserRepository>();
var userId = context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var user = userRepo.GetById(userId);
// If user no longer exists or is disabled
if (user == null || user.IsDisabled)
{
context.RejectPrincipal();
await context.HttpContext.SignOutAsync(
CookieAuthenticationDefaults.AuthenticationScheme);
}
}
};
});
2.7 SameSite Cookies and CSRF Protection
CSRF Attack (Cross-Site Request Forgery)
┌──────────────────────────────────────────────────────────────────┐
│ CSRF ATTACK │
│ │
│ 1. User is logged into globomantics.com │
│ (identity cookie in browser) │
│ │
│ 2. User visits freetickets.com (malicious) │
│ The page HTML contains: │
│ │
│ <form action="https://globomantics.com/proposals/approve" │
│ method="POST"> │
│ <input type="hidden" name="proposalId" value="4237" /> │
│ <button>Click here for a free ticket!</button> │
│ </form> │
│ │
│ 3. User clicks → POST request is sent │
│ to globomantics.com WITH the cookie (same domain) │
│ │
│ 4. globomantics.com approves proposal 4237! │
│ (without the user knowing) │
└──────────────────────────────────────────────────────────────────┘
SameSite Cookie Options
| Value | Behavior |
|---|---|
Strict | Cookie never sent from another site |
Lax | Exception: hyperlinks from other sites (default in modern browsers) |
None | Disables SameSite protection |
ASP.NET Core sets the SameSite flag to Lax by default.
// Program.cs — configure SameSite
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
// More secure if no links to the app from other sites
options.Cookie.SameSite = SameSiteMode.Strict;
});
2.8 External Identity Providers (Google)
Authentication flow with an external provider
┌──────────────────────────────────────────────────────────────────┐
│ AUTHENTICATION FLOW WITH GOOGLE │
│ │
│ Browser Application Google IdP │
│ │ │ │ │
│ │── GET /proposals ─►│ │ │
│ │ │ [not authenticated]│ │
│ │◄── Redirect ───────│ │ │
│ │ to Google │ │ │
│ │ │ │ │
│ │── GET /google/auth ────────────────────►│ │
│ │ │ client_id, │ │
│ │ │ redirect_uri │ │
│ │◄─── Login Page ───────────────────────│ │
│ │ │ │ │
│ │── Submit creds ───────────────────────►│ │
│ │ │ │ │
│ │◄── Redirect ──────────────────────────│ │
│ │ to redirect_uri│ │ │
│ │ + claims (OIDC)│ │ │
│ │ │ │ │
│ │── GET /signin-google ►│ │ │
│ │ │ Claims received │ │
│ │ │ → Cookie created │ │
│ │◄── Cookie + Redirect ─│ │ │
│ │ to /proposals │ │ │
└──────────────────────────────────────────────────────────────────┘
Configuring Google Authentication
# Required NuGet package
dotnet add package Microsoft.AspNetCore.Authentication.Google
// Program.cs
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// The challenge (when not authenticated) redirects to Google
options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
})
.AddCookie()
.AddGoogle(options =>
{
// Use Secret Manager in production, not hardcoded strings!
options.ClientId = builder.Configuration["Authentication:Google:ClientId"];
options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
});
Scheme Actions (actions triggered by the framework):
- Authenticate: rebuild ClaimsPrincipal (→ keep Cookie scheme)
- Challenge: what to do if not authenticated (→ can redirect to Google)
- Forbid: what to do if access denied (→ redirect to /Account/AccessDenied)
Login with Google (explicit button)
// AccountController.cs
[AllowAnonymous]
public IActionResult LoginWithGoogle(string returnUrl = "/")
{
return Challenge(
new AuthenticationProperties
{
RedirectUri = Url.Action("GoogleLoginCallback"),
Items = { { "returnUrl", returnUrl } }
},
GoogleDefaults.AuthenticationScheme);
}
public async Task<IActionResult> GoogleLoginCallback()
{
// Read Google claims (without creating a persistent cookie)
var result = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme);
// Get Google subject ID
var googleUserId = result.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
// Associate with local user
var user = _userRepository.GetByGoogleId(googleUserId);
if (user != null)
{
// Create local claims
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.Role, user.Role)
};
var identity = new ClaimsIdentity(
claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
// Sign in with local cookie
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme, principal);
}
var returnUrl = result.Properties?.Items["returnUrl"] ?? "/";
return LocalRedirect(returnUrl);
}
Difference between
SignInAsyncandAuthenticateAsync:
SignInAsync: creates and persists the identity cookieAuthenticateAsync: rebuilds the ClaimsPrincipal from a scheme, without creating a cookie
2.9 Claims Transformation with External Providers
Claims transformation allows enriching claims from an external provider with local claims.
┌──────────────────────────────────────────────────────────────────┐
│ CLAIMS TRANSFORMATION │
│ │
│ Google Claims + Local Claims (DB) │
│ ───────────── ─────────────────── │
│ sub = google-123 role = organizer │
│ name = Jane Smith permission = addconference │
│ email = j@example.com favoriteColor = blue │
│ │
│ ↓ Claims Transformation ↓ │
│ │
│ Final identity cookie │
│ ────────────────────────── │
│ sub = google-123 │
│ name = Jane Smith │
│ email = j@example.com │
│ role = organizer │
│ permission = addconference │
│ favoriteColor = blue │
└──────────────────────────────────────────────────────────────────┘
3. ASP.NET Core Identity
3.1 Discovering Identity
ASP.NET Core Identity provides a complete cookie-based authentication solution, including:
| Feature | Description |
|---|---|
| User registration | Registration page with validation |
| Password management | Complexity policy, reset |
| Email confirmation | Token sent by email |
| 2FA authentication | Via authenticator app (TOTP) |
| Account lockout | After N failed attempts |
| User profile | Information management page |
| Data download | GDPR compliance |
| External providers | Google, Facebook, etc. |
Create a project with Identity: in Visual Studio, choose Individual Accounts as the authentication type.
3.2 Essentials — Program.cs and DbContext
// Program.cs — basic structure
var builder = WebApplication.CreateBuilder(args);
// Connect to SQL Server
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
// Register the DbContext (Entity Framework)
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
// Add Identity with all its services
builder.Services.AddDefaultIdentity<IdentityUser>(options =>
{
// Configuration examples
options.SignIn.RequireConfirmedAccount = true;
options.Password.RequireNonAlphanumeric = false; // Disable special char requirement
options.Password.RequiredLength = 8;
options.Lockout.MaxFailedAccessAttempts = 5;
})
.AddEntityFrameworkStores<ApplicationDbContext>(); // Use EF for storage
builder.Services.AddRazorPages();
var app = builder.Build();
app.UseAuthentication(); // Added automatically by Identity
app.UseAuthorization();
app.MapRazorPages();
app.Run();
ApplicationDbContext
// Data/ApplicationDbContext.cs
public class ApplicationDbContext : IdentityDbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
// Application-specific tables
public DbSet<Conference> Conferences { get; set; }
public DbSet<Proposal> Proposals { get; set; }
}
Tables created by Identity (prefix AspNet):
AspNetUsers — Users
AspNetRoles — Roles
AspNetUserRoles — User ↔ Role associations
AspNetUserClaims — Additional claims per user
AspNetRoleClaims — Claims associated with roles
AspNetUserLogins — External logins (Google, Facebook...)
AspNetUserTokens — Tokens (email confirmation, password reset, 2FA)
Identity options categories
IdentityOptions
├── Password → RequireDigit, RequireUppercase, MinLength, etc.
├── SignIn → RequireConfirmedEmail, RequireConfirmedPhone
├── Lockout → MaxFailedAccessAttempts, DefaultLockoutTimeSpan
├── User → AllowedUserNameCharacters, RequireUniqueEmail
├── Stores → MaxLengthForKeys, ProtectPersonalData
└── Tokens → Token lifetimes, providers
3.3 Customizing the User Interface
Identity pages are in a NuGet assembly (Microsoft.AspNetCore.Identity.UI). To customize them, they need to be scaffolded:
Right-click project → Add → Scaffolded Item → Identity
→ Select pages to customize (e.g., Login, Register)
→ Choose the DbContext
Scaffolded pages appear in Areas/Identity/Pages/Account/. Identity uses local pages before those in the assembly.
Login page structure (code-behind)
// Areas/Identity/Pages/Account/Login.cshtml.cs
public class LoginModel : PageModel
{
private readonly SignInManager<IdentityUser> _signInManager;
public LoginModel(SignInManager<IdentityUser> signInManager)
{
_signInManager = signInManager;
}
[BindProperty]
public InputModel Input { get; set; }
public class InputModel
{
[Required]
[EmailAddress]
public string Email { get; set; }
[Required]
[DataType(DataType.Password)]
public string Password { get; set; }
[Display(Name = "Remember me?")]
public bool RememberMe { get; set; }
}
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
returnUrl ??= Url.Content("~/");
if (ModelState.IsValid)
{
// SignInManager handles creating the ClaimsPrincipal and cookie
var result = await _signInManager.PasswordSignInAsync(
Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
if (result.Succeeded)
return LocalRedirect(returnUrl);
if (result.RequiresTwoFactor)
return RedirectToPage("./LoginWith2fa", ...);
if (result.IsLockedOut)
return RedirectToPage("./Lockout");
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
}
return Page();
}
}
3.4 Adding Identity to an Existing Project
Step 1: Create a custom User class
// Data/ApplicationUser.cs
public class ApplicationUser : IdentityUser
{
// Additional property in AspNetUsers
public DateTime CareerStarted { get; set; }
// Mark as personal data (downloadable GDPR)
[PersonalData]
public string Department { get; set; }
}
Step 2: DbContext with ApplicationUser
// Data/ApplicationDbContext.cs
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<Conference> Conferences { get; set; }
public DbSet<Proposal> Proposals { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// Seed data
builder.Entity<Conference>().HasData(
new Conference { Id = 1, Name = "NDC Oslo 2024" },
new Conference { Id = 2, Name = "DDD Europe 2024" }
);
}
}
Step 3: Program.cs with ApplicationUser
// Program.cs
builder.Services.AddDefaultIdentity<ApplicationUser>(options =>
{
options.SignIn.RequireConfirmedAccount = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>();
Step 4: Entity Framework Migration
# Entity Framework CLI
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet ef migrations add InitialIdentitySetup
dotnet ef database update
3.5 Working with Claims
Claims generated by Identity
When logging in with Identity, these claims are automatically created:
| Claim | Source | Description |
|---|---|---|
nameidentifier | AspNetUsers.Id | Primary key (GUID) |
name | AspNetUsers.UserName | Username |
emailaddress | AspNetUsers.Email | |
SecurityStamp | AspNetUsers.SecurityStamp | Random value, changes if credentials change |
amr | — | Authentication Method Reference |
Claims Transformation
To add custom claims (e.g., CareerStarted):
// Infrastructure/ClaimsTransformer.cs
public class ClaimsTransformer : IClaimsTransformation
{
private readonly IUserStore<ApplicationUser> _userStore;
public ClaimsTransformer(IUserStore<ApplicationUser> userStore)
{
_userStore = userStore;
}
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
// Work on a clone to avoid concurrency issues
var clonedPrincipal = principal.Clone();
var identity = clonedPrincipal.Identity as ClaimsIdentity;
if (identity == null || !identity.IsAuthenticated)
return clonedPrincipal;
// Avoid duplicates (method can be called multiple times)
if (identity.HasClaim(c => c.Type == AppClaimTypes.CareerStarted))
return clonedPrincipal;
// Get the user ID
var userId = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId == null) return clonedPrincipal;
// Get the user from DB
var user = await _userStore.FindByIdAsync(userId, CancellationToken.None);
if (user != null)
{
identity.AddClaim(new Claim(
AppClaimTypes.CareerStarted,
user.CareerStarted.ToString("yyyy-MM-dd")));
}
return clonedPrincipal;
}
}
// Constants class for custom claim types
public static class AppClaimTypes
{
public const string CareerStarted = "careerstarted";
public const string BirthDate = "birthdate";
public const string CompanyName = "companyname";
}
// Program.cs — register the ClaimsTransformer
builder.Services.AddScoped<IClaimsTransformation, ClaimsTransformer>();
// Multiple transformers can be registered
SecurityStamp: a random value in
AspNetUsers. Stored in the cookie, compared on each request. If the value changes (e.g., password change, account locked), the cookie is rejected and the user must re-login.
3.6 UserClaims and Adding Claims on Registration
Two methods for adding claims
Method 1: AspNetUserClaims table (simpler)
// During user registration
var result = await _userManager.CreateAsync(user, password);
if (result.Succeeded)
{
// Add a claim in AspNetUserClaims
await _userManager.AddClaimAsync(user,
new Claim(AppClaimTypes.BirthDate, birthDate.ToString("yyyy-MM-dd")));
}
Identity automatically reads these claims from AspNetUserClaims.
Method 2: Property on ApplicationUser (with migration)
public class ApplicationUser : IdentityUser
{
public DateTime CareerStarted { get; set; }
}
// Requires an EF migration and a ClaimsTransformer
Customized Registration Page
// Areas/Identity/Pages/Account/Register.cshtml.cs
public class RegisterModel : PageModel
{
private readonly UserManager<ApplicationUser> _userManager;
[BindProperty]
public InputModel Input { get; set; }
public class InputModel
{
[Required, EmailAddress]
public string Email { get; set; }
[Required, DataType(DataType.Password)]
public string Password { get; set; }
// Additional fields
public DateTime CareerStarted { get; set; }
[DataType(DataType.Date)]
public DateTime BirthDate { get; set; }
}
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
if (!ModelState.IsValid) return Page();
var user = new ApplicationUser
{
UserName = Input.Email,
Email = Input.Email,
CareerStarted = Input.CareerStarted // Custom property
};
var result = await _userManager.CreateAsync(user, Input.Password);
if (result.Succeeded)
{
// Add BirthDate in AspNetUserClaims
await _userManager.AddClaimAsync(user,
new Claim(AppClaimTypes.BirthDate,
Input.BirthDate.ToString("yyyy-MM-dd")));
// Optional: add to a role
await _userManager.AddToRoleAsync(user, "Speaker");
await _signInManager.SignInAsync(user, isPersistent: false);
return LocalRedirect(returnUrl ?? "~/");
}
foreach (var error in result.Errors)
ModelState.AddModelError(string.Empty, error.Description);
return Page();
}
}
3.7 Roles
Role Architecture in Identity
┌─────────────────────────────────────────────────────────────────┐
│ ROLES STRUCTURE │
│ │
│ AspNetUsers AspNetRoles AspNetRoleClaims │
│ ────────────── ─────────── ──────────────── │
│ Id: user-1 Id: role-1 RoleId: role-1 │
│ Name: Alice Name: Organizer Type: Permission │
│ Value: AddConference │
│ Id: user-2 Id: role-2 │
│ Name: Bob Name: Speaker RoleId: role-2 │
│ Type: Permission │
│ AspNetUserRoles Value: AddProposal │
│ ───────────────── │
│ UserId: user-1, RoleId: role-1 → Alice is Organizer │
│ UserId: user-2, RoleId: role-2 → Bob is Speaker │
│ │
│ Resulting claims for Alice: │
│ role = Organizer │
│ Permission = AddConference │
└─────────────────────────────────────────────────────────────────┘
Enabling Roles
// Program.cs
builder.Services.AddDefaultIdentity<ApplicationUser>(options => ...)
.AddRoles<IdentityRole>() // BEFORE AddEntityFrameworkStores!
.AddEntityFrameworkStores<ApplicationDbContext>();
Using RoleManager
// Injecting RoleManager
public class AdminController : Controller
{
private readonly RoleManager<IdentityRole> _roleManager;
private readonly UserManager<ApplicationUser> _userManager;
public AdminController(
RoleManager<IdentityRole> roleManager,
UserManager<ApplicationUser> userManager)
{
_roleManager = roleManager;
_userManager = userManager;
}
public async Task<IActionResult> SetupRoles()
{
// Create a role
if (!await _roleManager.RoleExistsAsync("Organizer"))
{
await _roleManager.CreateAsync(new IdentityRole("Organizer"));
}
// Add a claim to a role
var role = await _roleManager.FindByNameAsync("Organizer");
await _roleManager.AddClaimAsync(role,
new Claim("Permission", "AddConference"));
// Assign a role to a user
var user = await _userManager.FindByEmailAsync("alice@example.com");
await _userManager.AddToRoleAsync(user, "Organizer");
return Ok();
}
}
Roles disabled by default:
AddDefaultIdentitydoes not enable roles. You must explicitly callAddRoles<IdentityRole>().
3.8 Token Providers and Email
Identity uses tokens for email confirmation and password reset. These tokens are stored in AspNetUserTokens.
// Implement IEmailSender
public class EmailSender : IEmailSender
{
public async Task SendEmailAsync(string email, string subject, string htmlMessage)
{
// Implement with SmtpClient, SendGrid, MailKit, etc.
using var client = new SmtpClient("smtp.example.com");
var mail = new MailMessage("noreply@globomantics.com", email, subject, htmlMessage);
mail.IsBodyHtml = true;
await client.SendMailAsync(mail);
}
}
// Program.cs
builder.Services.AddTransient<IEmailSender, EmailSender>();
Generating an email confirmation token
// In the Register page
if (result.Succeeded)
{
// Generate the token (stored in AspNetUserTokens)
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
// Encode to Base64 for the URL
var code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
// Build the confirmation link
var callbackUrl = Url.Page(
"/Account/ConfirmEmail",
pageHandler: null,
values: new { area = "Identity", userId = user.Id, code },
protocol: Request.Scheme);
await _emailSender.SendEmailAsync(
Input.Email,
"Confirm your email",
$"Click <a href='{callbackUrl}'>here</a> to confirm your account.");
}
Token lifetime
// Program.cs — configure token lifetime
builder.Services.Configure<DataProtectionTokenProviderOptions>(options =>
{
options.TokenLifespan = TimeSpan.FromHours(24); // 1 day by default
});
3.9 Two-Factor Authentication (2FA)
Identity supports 2FA via authenticator apps (Google Authenticator, Microsoft Authenticator) using the TOTP protocol (Time-based One-Time Password).
Adding the QR Code
// wwwroot/js/qr.js
window.addEventListener("DOMContentLoaded", () => {
const uri = document.getElementById("qrCodeData").getAttribute("data-url");
new QRCode(document.getElementById("qrCode"), {
text: uri,
width: 200,
height: 200
});
});
2FA Flow
Normal login (email + password)
│
▼
Identity detects 2FA is enabled
│
▼
Redirect to /Account/LoginWith2fa
│
▼
User enters TOTP code (6 digits)
│
▼
Identity verifies the code
│
┌──────┴──────┐
│ │
OK Fail
│ │
▼ ▼
Logged in New code
or recovery code
3.10 External Identity Providers with Identity
// Program.cs — add Google after AddDefaultIdentity
builder.Services.AddDefaultIdentity<ApplicationUser>(...)
.AddEntityFrameworkStores<ApplicationDbContext>();
// Add Google (on the builder returned by AddAuthentication)
builder.Services.AddAuthentication()
.AddGoogle(options =>
{
options.ClientId = builder.Configuration["Authentication:Google:ClientId"];
options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
});
Identity automatically handles:
- The temporary cookie for the external identity
- The
AspNetUserLoginsassociation (Google userId ↔ ApplicationUser) - The
ExternalLoginpage for associating an existing account
3.11 Architecture and Advanced Customization
┌─────────────────────────────────────────────────────────────────┐
│ IDENTITY ARCHITECTURE │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ MANAGERS │ │
│ │ UserManager<T> RoleManager<T> │ │
│ │ SignInManager<T> │ │
│ │ (High-level operations: create user, sign in, etc.) │ │
│ └──────────────────────────┬────────────────────────────────┘ │
│ │ use │
│ ┌──────────────────────────▼────────────────────────────────┐ │
│ │ STORES │ │
│ │ UserStore<T> RoleStore<T> │ │
│ │ (LINQ queries, CRUD in DB) │ │
│ └──────────────────────────┬────────────────────────────────┘ │
│ │ use │
│ ┌──────────────────────────▼────────────────────────────────┐ │
│ │ DATA ACCESS LAYER │ │
│ │ Entity Framework Core (by default) │ │
│ │ → SQL Server, SQLite, PostgreSQL, etc. │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ Each layer can be replaced by a custom implementation │
└─────────────────────────────────────────────────────────────────┘
Example: Custom UserStore (with Company relationship)
// Data/CustomUserStore.cs
public class CustomUserStore : UserStore<ApplicationUser>
{
private readonly ApplicationDbContext _dbContext;
public CustomUserStore(ApplicationDbContext dbContext, IdentityErrorDescriber describer = null)
: base(dbContext, describer)
{
_dbContext = dbContext;
}
// Override to include Company in all queries
public override async Task<ApplicationUser> FindByIdAsync(
string userId, CancellationToken cancellationToken = default)
{
return await _dbContext.Users
.Include(u => u.Company) // Eagerly load Company
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
}
}
// Program.cs — register the custom UserStore
builder.Services.AddDefaultIdentity<ApplicationUser>(...)
.AddUserStore<CustomUserStore>() // Replaces the default UserStore
.AddEntityFrameworkStores<ApplicationDbContext>();
4. Multi-Application Authentication with OpenID Connect
4.1 Concepts — Identity Provider
When multiple applications need to share authentication, an Identity Provider (IdP) centralizes this process.
┌─────────────────────────────────────────────────────────────────┐
│ ARCHITECTURE WITH IDENTITY PROVIDER │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Web App │ │ Mobile App │ │ API │ │
│ │ (Client) │ │ (Client) │ │ (Resource) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ │ Trust │ Trust │ Trust │
│ └───────────────────┴───────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Identity Provider│ │
│ │ │ │
│ │ - User Store │ │
│ │ - Login UI │ │
│ │ - 2FA │ │
│ │ - Single Sign-On│ │
│ └─────────────────┘ │
│ │
│ Benefits: │
│ ✓ Single Sign-On (SSO) — one login for everything │
│ ✓ Centralized user management │
│ ✓ Authentication outside the application scope │
└─────────────────────────────────────────────────────────────────┘
Passport analogy
| Real world | Application world |
|---|---|
| Hotel | Client (application) |
| Government issuing the passport | Identity Provider |
| Passport | Token |
| Trust relationship | Client configuration in IdP |
| Traveler | User |
Two types of tokens
┌──────────────────────────────────────────────────────────────────┐
│ TOKENS │
│ │
│ ┌───────────────────────┐ ┌──────────────────────────────┐ │
│ │ IDENTITY TOKEN │ │ ACCESS TOKEN │ │
│ │ │ │ │ │
│ │ • Contains user │ │ • Access key to an API │ │
│ │ claims │ │ • Can contain claims │ │
│ │ • Consumed by the │ │ • Sent in the header │ │
│ │ client (then │ │ Authorization: Bearer ... │ │
│ │ discarded) │ │ • Standard: OAuth 2.0 │ │
│ │ • Creates the cookie │ │ │ │
│ │ • Standard: OIDC │ │ │ │
│ └───────────────────────┘ └──────────────────────────────┘ │
│ │
│ OpenID Connect = OAuth 2.0 + Identity Token support │
└──────────────────────────────────────────────────────────────────┘
4.2 Client Authentication Process
┌──────────────────────────────────────────────────────────────────┐
│ OPENID CONNECT FLOW (OVERVIEW) │
│ │
│ Browser Client App Identity Provider │
│ │ │ │ │
│ │─ GET /conf ───►│ │ │
│ │ │ [not authenticated] │ │
│ │◄── Redirect ───│ │ │
│ │ to IdP │ │ │
│ │ │ │ │
│ │─ GET /authorize?─────────────────────►│ │
│ │ client_id=globomantics_web │ │
│ │ scope=openid profile email │ │
│ │ response_type=code │ │
│ │ redirect_uri=/signin-oidc │ │
│ │ code_challenge=... (PKCE) │ │
│ │ │ │ │
│ │◄─────────────────────── Login Page ──│ │
│ │ │ │ │
│ │─ POST creds ──────────────────────────►│ │
│ │ │ │ │
│ │◄─── Redirect ─────────────────────────│ │
│ │ to /signin-oidc?code=xyz │ │
│ │ │ │ │
│ │─ GET /signin-oidc?code=xyz ──►│ │ │
│ │ │ │ │ │
│ │ │◄─── POST /token ─────►│ (back channel) │
│ │ │ code=xyz, │ │
│ │ │ code_verifier │ │
│ │ │ │ │
│ │ │◄─ id_token, ──────────│ │
│ │ │ access_token │ │
│ │ │ │ │
│ │ │ Creates identity cookie│ │
│ │◄── Cookie ─────│ │ │
│ │ + Redirect │ │ │
│ │ to /conf │ │ │
└──────────────────────────────────────────────────────────────────┘
Authorization endpoint request parameters
| Parameter | Description |
|---|---|
client_id | Client identifier registered in IdP |
scope | Requested claims and APIs (openid profile email) |
response_type | code for Authorization Code Flow |
redirect_uri | Return URL after authentication |
code_challenge | PKCE protection |
state | CSRF protection (nonce) |
Client configuration (Program.cs)
// Program.cs — OpenID Connect configuration
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
// Identity Provider URL (trusted authority)
options.Authority = "https://localhost:5000";
options.ClientId = "globomantics_web";
options.ClientSecret = "secret"; // Do not hardcode in production!
// Authorization Code Flow (most secure)
options.ResponseType = "code";
// Requested scopes
options.Scope.Clear();
options.Scope.Add("openid"); // Required to get an identity token
options.Scope.Add("profile"); // First name, last name, etc.
options.Scope.Add("email"); // Email + email_verified
options.Scope.Add("globomantics"); // Custom scope: role, permission, careerstarted
options.Scope.Add("globomantics_api"); // API scope: get an access token
// Save the access token in the cookie
options.SaveTokens = true;
// Retrieve claims from UserInfo endpoint (if token too large)
options.GetClaimsFromUserInfoEndpoint = true;
// ASP.NET Core 8+: disable automatic claim mapping
options.MapInboundClaims = false;
});
4.3 Authorization Code Flow and PKCE
Why not send tokens directly in the redirect?
The browser (front channel) is unsecure: everything transiting through it can be read and manipulated.
┌──────────────────────────────────────────────────────────────────┐
│ AUTHORIZATION CODE FLOW │
│ │
│ FRONT CHANNEL (browser — unsecure) │
│ ──────────────────────────────────── │
│ │
│ Client → IdP: authentication request │
│ IdP → Client: random code (NOT the tokens!) │
│ │
│ BACK CHANNEL (server to server — secure) │
│ ────────────────────────────────────── │
│ │
│ Client → IdP: exchange code for tokens │
│ (with client_id + client_secret) │
│ IdP → Client: id_token + access_token │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ + PKCE │ │
│ │ │ │
│ │ 1. Client generates a random secret (code_verifier) │ │
│ │ 2. Hash of secret (code_challenge) sent with code │ │
│ │ 3. IdP associates the hash with the issued code │ │
│ │ 4. During exchange, client sends code_verifier │ │
│ │ 5. IdP verifies: hash(code_verifier) == code_challenge │ │
│ │ │ │
│ │ → Even if an attacker intercepts the code, │ │
│ │ they cannot exchange it without the code_verifier! │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ PKCE is enabled by default in ASP.NET Core middleware! │
└──────────────────────────────────────────────────────────────────┘
4.4 Duende IdentityServer
Duende IdentityServer transforms an ASP.NET Core application into an Identity Provider implementing OpenID Connect/OAuth 2.0.
# Install IdentityServer templates
dotnet new install Duende.IdentityServer.Templates
# Create a project with Identity
dotnet new isaspid -n IdentityProvider
// Program.cs of the Identity Provider
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddDefaultIdentity<ApplicationUser>(options =>
options.SignIn.RequireConfirmedAccount = false)
.AddEntityFrameworkStores<ApplicationDbContext>();
// Configure IdentityServer
builder.Services.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
})
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryClients(Config.Clients)
.AddAspNetIdentity<ApplicationUser>();
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseIdentityServer(); // Includes UseAuthentication
app.UseAuthorization();
app.MapRazorPages().RequireAuthorization();
app.Run();
4.5 Configuring an Identity Provider
// Config.cs
public static class Config
{
// IDENTITY SCOPES (baskets of user claims)
public static IEnumerable<IdentityResource> IdentityResources =>
new List<IdentityResource>
{
new IdentityResources.OpenId(), // Contains: sub (subject ID)
new IdentityResources.Profile(), // Contains: name, given_name, family_name...
new IdentityResources.Email(), // Contains: email, email_verified
new IdentityResource
{
Name = "globomantics",
DisplayName = "Globomantics Data",
UserClaims = new[] { "role", "permission", "careerstarted" }
}
};
// API SCOPES (API access)
public static IEnumerable<ApiScope> ApiScopes =>
new List<ApiScope>
{
new ApiScope("globomantics_api", "Globomantics API")
};
// CLIENTS (applications using this IdP)
public static IEnumerable<Client> Clients =>
new List<Client>
{
new Client
{
ClientId = "globomantics_web",
ClientName = "Globomantics Web Application",
// Hashed secret (do not store in plain text in production!)
ClientSecrets = { new Secret("secret".Sha256()) },
// Allow only Authorization Code Flow
AllowedGrantTypes = GrantTypes.Code,
// Always include all user claims in identity token
AlwaysIncludeUserClaimsInIdToken = true,
// Allowed return URLs
RedirectUris = { "https://localhost:5001/signin-oidc" },
// URL for logout via IdP
FrontChannelLogoutUri = "https://localhost:5001/signout-oidc",
// URL after logout
PostLogoutRedirectUris = { "https://localhost:5001/signout-callback-oidc" },
// Allowed scopes for this client
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email,
"globomantics",
"globomantics_api"
},
// Display consent screen (useful for public IdP)
RequireConsent = false
}
};
}
4.6 External Providers in IdentityServer
// Program.cs of the Identity Provider — add Google
builder.Services.AddAuthentication()
.AddGoogle(options =>
{
options.ClientId = "...";
options.ClientSecret = "...";
// Use IdentityServer's cookie container for external IdPs
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
});
IdentityServer already has an ExternalLogin page that handles external account association.
Home Realm Discovery: process where the user enters their email first, and the application automatically determines which login to use (local or external).
4.7 Extending IdentityServer — Profile Service
The Profile Service controls which claims are included in tokens.
// Infrastructure/CustomProfileService.cs
public class CustomProfileService : ProfileService<ApplicationUser>
{
public CustomProfileService(
UserManager<ApplicationUser> userManager,
IUserClaimsPrincipalFactory<ApplicationUser> claimsFactory)
: base(userManager, claimsFactory)
{
}
protected override async Task GetProfileDataAsync(
ProfileDataRequestContext context,
ApplicationUser user)
{
// Call base method to get Identity claims
var principal = await GetUserClaimsAsync(user);
var claimsIdentity = (ClaimsIdentity)principal.Identity;
// Add additional claims
if (!claimsIdentity.HasClaim(c => c.Type == AppClaimTypes.CareerStarted))
{
claimsIdentity.AddClaim(new Claim(
AppClaimTypes.CareerStarted,
user.CareerStarted.ToString("yyyy-MM-dd")));
}
context.AddRequestedClaims(principal.Claims);
}
}
// Program.cs — register AFTER AddAspNetIdentity
builder.Services.AddIdentityServer()
...
.AddAspNetIdentity<ApplicationUser>()
.AddProfileService<CustomProfileService>(); // Must be AFTER AddAspNetIdentity!
4.8 Anatomy of a JWT Token
A JWT (JSON Web Token) is composed of three parts separated by dots, encoded in Base64:
HEADER.PAYLOAD.SIGNATURE
Decoding example (on jwt.io)
// HEADER
{
"alg": "RS256", // Signing algorithm
"kid": "key-id", // ID of the public key used
"typ": "JWT"
}
// PAYLOAD (claims)
{
"iss": "https://localhost:5000", // Issuer — IdP URL
"nbf": 1700000000, // Not Before (Unix timestamp)
"iat": 1700000000, // Issued At
"exp": 1700003600, // Expiration (1h after)
"aud": "globomantics_web", // Audience — client ID
"amr": ["pwd"], // Authentication Method Reference
"nonce": "abc123", // Security: must match the one sent
"at_hash": "xyz...", // Hash of the access token
"sid": "session-id", // Session ID
"auth_time": 1700000000, // Authentication time
// User claims
"sub": "user-456", // Subject ID
"email": "alice@example.com",
"birthdate": "1985-03-20",
"role": "organizer",
"careerstarted": "2012-09-01"
}
// SIGNATURE (encrypted with IdP's private key)
// Verifiable with the PUBLIC key from the JWKS endpoint
Signature verification (asymmetric encryption)
┌──────────────────────────────────────────────────────────────────┐
│ JWT TOKEN VERIFICATION │
│ │
│ Identity Provider │
│ ───────────────── │
│ 1. Creates the token (header + payload) │
│ 2. Hashes the content │
│ 3. Encrypts the hash with its PRIVATE KEY → Signature │
│ 4. Attaches the signature to the token │
│ │
│ Client (application) │
│ ───────────────────── │
│ 1. Receives the token │
│ 2. Downloads the PUBLIC KEY from the JWKS endpoint │
│ (standardized URL: /.well-known/openid-configuration/jwks) │
│ 3. Decrypts the signature with the public key → original hash │
│ 4. Hashes the token content itself │
│ 5. Compares the two hashes │
│ → Identical = authentic and unmodified token │
│ → Different = rejected │
│ │
│ Key rotation every 90 days (IdentityServer) │
│ Old key valid for 14 days after rotation │
└──────────────────────────────────────────────────────────────────┘
Important: The JWT is NOT encrypted, just Base64 encoded. Only put non-sensitive claims in it. The signature guarantees authenticity but not confidentiality.
4.9 Claims Mapping and Token Optimization
Disable automatic namespace mapping
// ASP.NET Core 8+
options.MapInboundClaims = false;
// ASP.NET Core < 8 (in Program.cs, before AddAuthentication)
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
UserInfo Endpoint for large tokens
// Client Program.cs — retrieve claims from UserInfo endpoint
options.GetClaimsFromUserInfoEndpoint = true;
// Only sub will be in the identity token
// Config.cs (IdP) — disable automatic inclusion
AlwaysIncludeUserClaimsInIdToken = false,
// Client Program.cs — manually map claims from UserInfo
options.ClaimActions.MapUniqueJsonKey("careerstarted", "careerstarted");
options.ClaimActions.MapUniqueJsonKey("role", "role");
options.ClaimActions.MapUniqueJsonKey("permission", "permission");
4.10 Ready-to-Use Identity Providers
| Solution | Type | Advantages | Disadvantages |
|---|---|---|---|
| Duende IdentityServer | Self-hosted | Maximum flexibility | License, maintenance |
| Auth0 | Cloud | Easy to configure | Limited extensibility |
| Okta | Cloud | Enterprise integrations | Cost |
| Azure AD / Entra ID | Microsoft Cloud | VS/Azure integration | Tenant complexity |
| Cloud | Free, universal | For public apps | |
| Windows (Kerberos) | Local network | Automatic SSO | LAN only |
Example with Azure AD
// Program.cs
builder.Services.AddAuthentication()
.AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));
// appsettings.json
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "contoso.com",
"TenantId": "tenant-guid",
"ClientId": "app-guid"
}
}
5. Authentication for Single-Page Applications (BFF)
5.1 SPAs and Security Challenges
┌──────────────────────────────────────────────────────────────────┐
│ WHY SPAS ARE A SPECIAL CASE │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ BAD APPROACH ❌ │ │
│ │ │ │
│ │ const users = [ │ │
│ │ { username: "admin", password: "secret", role: "admin"}│ │
│ │ ]; ← VISIBLE IN THE BROWSER! │ │
│ │ │ │
│ │ const API_KEY = "my-secret-key"; ← ALSO VISIBLE! │ │
│ │ │ │
│ │ Problems: │ │
│ │ • All JS code is readable (F12) │ │
│ │ • Secrets cannot be hidden │ │
│ │ • An attacker can bypass UI checks │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ Fundamental rule: │
│ "A browser cannot keep secrets" │
│ │
│ → Always delegate authentication to a server application │
└──────────────────────────────────────────────────────────────────┘
5.2 The Cookie Approach with BFF
BFF (Backend For Frontend): an ASP.NET Core server hosts the SPA and handles authentication.
┌──────────────────────────────────────────────────────────────────┐
│ BFF PATTERN WITH COOKIES │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SAME DOMAIN (localhost:5001) │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌──────────────────────────┐ │ │
│ │ │ Blazor WASM │ │ ASP.NET Core Server │ │ │
│ │ │ (SPA) │ │ │ │ │
│ │ │ │ │ • GET /Account/Login │ │ │
│ │ │ → Requests │ │ • GET /Account/User │ │ │
│ │ │ with cookie │ │ • GET /api/conferences │ │ │
│ │ │ auto │ │ • GET /api/proposals │ │ │
│ │ │ │ │ │ │ │
│ │ │ ← Protected │ │ [Authorize] on all │ │ │
│ │ │ responses │ │ API endpoints │ │ │
│ │ └─────────────────┘ └──────────────────────────┘ │ │
│ │ │ │
│ │ Cookie SameSite=Strict: sent automatically │ │
│ │ on all requests to the same domain │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Reason for same domain: SameSite cookies │
│ Automatic CSRF protection │
└──────────────────────────────────────────────────────────────────┘
5.3 BFF with Cookie Authentication — Code
Server (ASP.NET Core)
// Program.cs — Server
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
// __Host: prefix prevents subdomains from modifying the cookie
options.Cookie.Name = "__Host-globomantics";
options.Cookie.SameSite = SameSiteMode.Strict;
// Return 401 instead of redirecting (the SPA handles the redirect)
options.Events = new CookieAuthenticationEvents
{
OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
};
});
var app = builder.Build();
app.UseBlazorFrameworkFiles(); // Serve Blazor WASM files
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapFallbackToFile("index.html"); // All other routes → Blazor
app.Run();
// Controllers/AccountController.cs
[ApiController]
[Route("[controller]")]
public class AccountController : ControllerBase
{
// Crucial endpoint: returns claims to the Blazor client
[HttpGet("User")]
[Authorize]
public IActionResult GetUserClaims()
{
var claims = User.Claims.Select(c => new UserClaim
{
Type = c.Type,
Value = c.Value
});
return Ok(claims);
}
}
Blazor WASM client — Custom AuthenticationStateProvider
// Authentication/CookieAuthenticationStateProvider.cs
public class CookieAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly HttpClient _httpClient;
public CookieAuthenticationStateProvider(HttpClient httpClient)
{
_httpClient = httpClient;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
try
{
// Call the /Account/User endpoint
var userClaims = await _httpClient
.GetFromJsonAsync<IEnumerable<UserClaim>>("Account/User?slide=false");
if (userClaims == null)
return new AuthenticationState(new ClaimsPrincipal());
// Rebuild the ClaimsPrincipal
var claims = userClaims.Select(c => new Claim(c.Type, c.Value));
var identity = new ClaimsIdentity(
claims,
authenticationType: "Cookie",
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role);
return new AuthenticationState(new ClaimsPrincipal(identity));
}
catch
{
// 401 → not authenticated
return new AuthenticationState(new ClaimsPrincipal());
}
}
}
// Program.cs Blazor
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
5.4 BFF with OpenID Connect
// Program.cs — Server with OpenID Connect + Duende.BFF
builder.Services.AddBff(options =>
{
options.ManagementBasePath = "/Account"; // /Account/Login, /Account/Logout, /Account/User
})
.AddServerSideSessions(); // Store tokens server-side (cookie remains small)
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://localhost:5000";
options.ClientId = "globomantics_bff";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.SaveTokens = true;
// ... scopes
});
// Map BFF endpoints
app.MapBffManagementEndpoints();
// Locally protected API (cookie is sufficient)
app.MapControllers().AsBffApiEndpoint();
// To proxy to an external API with the access token:
// (requires Duende.BFF.Yarp)
app.MapRemoteBffApiEndpoint("/api/conferences", "https://api.globomantics.com/conferences")
.WithAccessToken(RequiredTokenType.User);
┌──────────────────────────────────────────────────────────────────┐
│ BFF WITH OPENID CONNECT │
│ │
│ Browser/Blazor BFF Server IdP External API │
│ │ │ │ │ │
│ 1. │─ Login ────────►│ │ │ │
│ │ │─ Redirect ──────►│ │ │
│ │ │ (OIDC auth) │ │ │
│ │◄───────────────│◄─ tokens ───────│ │ │
│ │ Cookie │ │ │ │
│ │ │ │ │ │
│ 2. │─ GET /api/conf ►│ │ │ │
│ │ + cookie │─ Forward ──────────────────── ►│ │
│ │ │ + Access Token│ │ │
│ │◄───────────────│◄────────────────────────────── │ │
│ │ Data │ │ │ │
│ │
│ The browser NEVER sees the tokens! │
│ The access token stays on the BFF server. │
└──────────────────────────────────────────────────────────────────┘
6. Authorization Policies
6.1 Authorization Policies — Definition
Policies allow defining authorization rules in a centralized way, reusable throughout the application.
// Program.cs — Policy definitions
builder.Services.AddAuthorization(options =>
{
// Simple policy: check for a claim with a value
options.AddPolicy("IsOrganizer", policy =>
policy.RequireRole("organizer"));
options.AddPolicy("IsSpeaker", policy =>
policy.RequireRole("speaker"));
// Check a custom claim
options.AddPolicy("CanAddConference", policy =>
policy.RequireClaim("permission", "addconference"));
// Check for the presence of a claim (regardless of value)
options.AddPolicy("HasDepartment", policy =>
policy.RequireClaim("department"));
// Combining requirements
options.AddPolicy("SeniorOrganizer", policy =>
{
policy.RequireRole("organizer");
policy.RequireClaim("careerstarted"); // AND logical
});
// FallbackPolicy: applied when NO [Authorize] is present
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
// DefaultPolicy: applied when [Authorize] is present WITHOUT a specific policy
// (default: RequireAuthenticatedUser)
});
Difference between FallbackPolicy and DefaultPolicy:
FallbackPolicy: applies when no[Authorize]attribute is presentDefaultPolicy: applies when[Authorize]without a policy is present
6.2 Applying Policies
// ConferenceController.cs
[Authorize] // Baseline: authenticated user required for entire controller
public class ConferenceController : Controller
{
[HttpGet]
public async Task<IActionResult> Index()
{
// Accessible to any authenticated user
return View(await _conferenceRepository.GetAll());
}
[HttpGet]
[Authorize(Policy = "CanAddConference")] // In addition to the baseline
public IActionResult Add()
{
return View(new ConferenceModel());
}
[HttpPost]
[Authorize(Policy = "CanAddConference")]
public async Task<IActionResult> Add(ConferenceModel model)
{
await _conferenceRepository.Add(model);
return RedirectToAction("Index");
}
}
// ProposalController.cs
[Authorize]
public class ProposalController : Controller
{
[HttpGet]
[Authorize(Policy = "IsSpeaker")] // Only speakers can add
public IActionResult Add() => View(new ProposalModel());
[HttpPost]
[Authorize(Policy = "IsSpeaker")]
public async Task<IActionResult> Add(ProposalModel model)
{
await _proposalRepository.Add(model);
return RedirectToAction("Index");
}
[HttpPost]
[Authorize(Policy = "IsOrganizer")] // Only organizers can approve
public async Task<IActionResult> Approve(int id)
{
await _proposalRepository.Approve(id);
return RedirectToAction("Index");
}
}
6.3 Policies in Razor Pages and Blazor
Razor Pages — Conventions
// Program.cs — Razor Pages Conventions (alternative to attributes)
builder.Services.AddRazorPages(options =>
{
// Protect a specific page
options.Conventions.AuthorizePage("/Contact");
// Protect a page with a policy
options.Conventions.AuthorizePage("/Proposals/Add", "IsSpeaker");
// Protect an entire folder
options.Conventions.AuthorizeFolder("/Admin", "IsOrganizer");
// Exclude a page from a protected folder
options.Conventions.AllowAnonymousToPage("/Admin/Login");
});
Blazor — AuthorizeView with policy
@* Policy check in a Blazor component *@
<AuthorizeView Policy="CanAddConference">
<Authorized>
<button @onclick="AddConference">Add a conference</button>
</Authorized>
<NotAuthorized>
<p>You don't have permission to add conferences.</p>
</NotAuthorized>
</AuthorizeView>
@* Role check *@
<AuthorizeView Roles="organizer">
<button>Action reserved for organizers</button>
</AuthorizeView>
6.4 Conditional Display with AuthorizationService
// Views/Conference/Index.cshtml
@using Microsoft.AspNetCore.Authorization
@inject IAuthorizationService AuthorizationService
@{
// Check policies in the code block (at the top of the view)
var canAddConference = await AuthorizationService
.AuthorizeAsync(User, "CanAddConference");
var isOrganizer = await AuthorizationService
.AuthorizeAsync(User, "IsOrganizer");
}
<div class="conference-list">
@foreach (var conference in Model.Conferences)
{
<div class="conference">
<h3>@conference.Name</h3>
@* Conditionally display based on policies *@
@if (canAddConference.Succeeded && isOrganizer.Succeeded)
{
<a asp-action="Edit" asp-route-id="@conference.Id">Edit</a>
}
</div>
}
@if (canAddConference.Succeeded)
{
<a asp-action="Add" class="btn btn-primary">Add a conference</a>
}
</div>
// Verification in a controller (direct injection)
public class ConferenceController : Controller
{
private readonly IAuthorizationService _authorizationService;
public ConferenceController(IAuthorizationService authorizationService)
{
_authorizationService = authorizationService;
}
public async Task<IActionResult> Edit(int id, ConferenceModel model)
{
var result = await _authorizationService
.AuthorizeAsync(User, model, "CanEditConference");
if (!result.Succeeded)
return RedirectToAction("AccessDenied", "Account");
// Continue processing...
}
}
Important: hiding UI elements is never sufficient! Always also protect controller actions with policies.
6.5 Requirements and Handlers
For more complex authorization, use Requirements (data) and Handlers (logic).
┌──────────────────────────────────────────────────────────────────┐
│ REQUIREMENTS AND HANDLERS │
│ │
│ ┌────────────────────┐ ┌────────────────────────────────┐ │
│ │ Requirement │ │ Handler │ │
│ │ │ │ │ │
│ │ Config data │ │ Authorization logic │ │
│ │ ex: minYears = 10 │────►│ │ │
│ │ │ │ context.User → claims │ │
│ │ implements │ │ requirement → data │ │
│ │ IAuthorizationReq.│ │ → Succeed() or Fail() │ │
│ └────────────────────┘ └────────────────────────────────┘ │
│ │
│ One requirement can have MULTIPLE handlers! │
│ Succeed from at least 1 handler → access granted │
│ Fail from a handler → access denied (overrides all Succeeds) │
│ No handler responds → access denied │
└──────────────────────────────────────────────────────────────────┘
Example: checking years of experience
// Authorization/YearsOfExperience/YearsOfExperienceRequirement.cs
public class YearsOfExperienceRequirement : IAuthorizationRequirement
{
public int MinimumYearsOfExperience { get; }
public YearsOfExperienceRequirement(int minimumYears)
{
MinimumYearsOfExperience = minimumYears;
}
}
// Authorization/YearsOfExperience/YearsOfExperienceHandler.cs
public class YearsOfExperienceHandler
: AuthorizationHandler<YearsOfExperienceRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
YearsOfExperienceRequirement requirement)
{
// Check for the claim
var careerStartedClaim = context.User
.FindFirst(c => c.Type == AppClaimTypes.CareerStarted);
if (careerStartedClaim == null)
{
// No claim → do nothing (let other handlers decide)
return Task.CompletedTask;
}
// Calculate years of experience
if (DateTime.TryParse(careerStartedClaim.Value, out var careerStart))
{
var yearsOfExperience = DateTime.Now.Year - careerStart.Year;
if (yearsOfExperience >= requirement.MinimumYearsOfExperience)
{
context.Succeed(requirement); // ✓ Access granted
}
}
return Task.CompletedTask;
}
}
// Program.cs — register requirement, handler and policy
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ExperiencedSpeaker", policy =>
{
policy.RequireRole("speaker");
policy.AddRequirements(new YearsOfExperienceRequirement(10)); // min 10 years
});
});
// IMPORTANT: register the handler in DI!
builder.Services.AddScoped<IAuthorizationHandler, YearsOfExperienceHandler>();
6.6 Resource-Based Authorization
For authorization based on object state (e.g., “only the author can edit their proposal”).
// Authorization/Proposal/ProposalRequirement.cs
public class ProposalRequirement : IAuthorizationRequirement
{
// No data here: we need the User and Proposal (passed to handler)
}
// Authorization/Proposal/ProposalAuthorizationHandler.cs
public class ProposalAuthorizationHandler
: AuthorizationHandler<ProposalRequirement, ProposalModel>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ProposalRequirement requirement,
ProposalModel resource) // ← The Proposal object
{
// Check that the user is the author AND that the proposal is not yet approved
var userName = context.User.FindFirst(ClaimTypes.Name)?.Value;
if (string.Equals(userName, resource.Speaker, StringComparison.OrdinalIgnoreCase)
&& !resource.IsApproved)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
// Program.cs
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanEditProposal", policy =>
policy.AddRequirements(new ProposalRequirement()));
});
builder.Services.AddScoped<IAuthorizationHandler, ProposalAuthorizationHandler>();
// ProposalController.cs — authorization with resource
[Authorize]
public class ProposalController : Controller
{
private readonly IAuthorizationService _authorizationService;
public ProposalController(IAuthorizationService authorizationService)
{
_authorizationService = authorizationService;
}
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
var proposal = await _proposalRepository.Get(id);
// Check the policy with the RESOURCE (proposal object)
var authResult = await _authorizationService
.AuthorizeAsync(User, proposal, "CanEditProposal");
if (!authResult.Succeeded)
return RedirectToAction("AccessDenied", "Account");
return View(proposal);
}
[HttpPost]
public async Task<IActionResult> Edit(int id, ProposalModel model)
{
var proposal = await _proposalRepository.Get(id);
// Same check in the POST!
var authResult = await _authorizationService
.AuthorizeAsync(User, proposal, "CanEditProposal");
if (!authResult.Succeeded)
return RedirectToAction("AccessDenied", "Account");
await _proposalRepository.Update(model);
return RedirectToAction("Index");
}
}
6.7 Access Revocation
Cookie lifetime
┌──────────────────────────────────────────────────────────────────┐
│ IDENTITY COOKIE LIFETIME │
│ │
│ Session cookie (isPersistent=false): │
│ → Destroyed when the BROWSER closes (not just the tab) │
│ │
│ Persistent cookie (isPersistent=true): │
│ → Default lifetime: 14 days │
│ → Sliding expiration: auto-renewal if > 7 days inactive │
│ │
│ Configuration options: │
│ ───────────────────────── │
│ MaxAge: destroys the cookie in the BROWSER at expiration │
│ ExpireTimeSpan: invalidates the authentication ticket (server) │
└──────────────────────────────────────────────────────────────────┘
// Program.cs — lifetime configuration
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
// Option 1: destroy the cookie in the browser after 1 day
options.Cookie.MaxAge = TimeSpan.FromDays(1);
// Option 2: invalidate the authentication ticket (cookie still there)
options.ExpireTimeSpan = TimeSpan.FromHours(8);
// Disable sliding expiration (auto-renewal)
options.SlidingExpiration = false;
});
Dynamic principal validation (immediate revocation)
// Program.cs — check on each request if access is still valid
builder.Services.AddAuthentication(...)
.AddCookie(options =>
{
options.Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = async context =>
{
var userRepo = context.HttpContext.RequestServices
.GetRequiredService<IUserRepository>();
var lastUpdatedClaim = context.Principal?
.FindFirst("LastUpdated")?.Value;
if (string.IsNullOrEmpty(lastUpdatedClaim))
{
context.RejectPrincipal();
await context.HttpContext.SignOutAsync(
CookieAuthenticationDefaults.AuthenticationScheme);
return;
}
var lastUpdated = DateTime.Parse(lastUpdatedClaim);
var userId = context.Principal?
.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var user = userRepo.GetById(userId);
if (user == null || user.LastChanged > lastUpdated)
{
// User disabled or data modified → revocation
context.RejectPrincipal();
await context.HttpContext.SignOutAsync(
CookieAuthenticationDefaults.AuthenticationScheme);
}
}
};
});
SecurityStamp in Identity
Identity uses SecurityStamp (field in AspNetUsers) for revocation:
- Random value, changes if: password changed, email changed, account locked, etc.
- Stored in the identity cookie
- Compared to the DB value on each request
- If different → cookie rejected, re-login required
// Force revocation of an Identity user
var user = await _userManager.FindByIdAsync(userId);
await _userManager.UpdateSecurityStampAsync(user);
// → All cookies for this user will be immediately invalid
Key Concepts Summary
┌──────────────────────────────────────────────────────────────────┐
│ SUMMARY — WHEN TO USE WHAT │
│ │
│ Cookie Authentication (bare) │
│ → Simple application, full control, no need for Identity │
│ │
│ ASP.NET Core Identity │
│ → Need registration, 2FA, password management │
│ → Web application with user database │
│ │
│ OpenID Connect + Identity Provider │
│ → Multiple applications sharing users │
│ → Single Sign-On required │
│ → APIs to protect with access tokens │
│ │
│ JWT Bearer │
│ → Protected REST APIs (mobile, SPA, service-to-service) │
│ → Stateless, horizontally scalable │
│ │
│ BFF (Backend For Frontend) │
│ → SPAs (Blazor WASM, React, Angular) │
│ → Maximum security (tokens never in the browser) │
│ │
│ Authorization Policies │
│ → Always use for authorization (centralized) │
│ → Requirements/Handlers for complex logic │
│ → Resource-based for authorization on objects │
└──────────────────────────────────────────────────────────────────┘
7. JWT Bearer Authentication (APIs)
7.1 When to Use JWT Bearer vs Cookie
| Criterion | Cookie | JWT Bearer |
|---|---|---|
| Application type | Web (MVC, Razor Pages) | REST API |
| Stateful/Stateless | Stateful (session) | Stateless |
| Client-side storage | Cookie (httpOnly) | localStorage / memory |
| Automatic sending | Yes (same domain) | No (manual header) |
| CSRF | Risk present | No risk |
| Mobile/Native | Difficult | Ideal |
| Scalability | Requires shared session | Easy horizontal |
7.2 JWT Bearer Configuration in an API
# NuGet package
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
// Program.cs — ASP.NET Core API
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// The authority is your Identity Provider URL
// The middleware automatically downloads JWKS keys from
// {Authority}/.well-known/openid-configuration
options.Authority = "https://localhost:5000";
// The audience must match the API scope in the token
options.Audience = "globomantics_api";
// Needed in development if IdP is on HTTP
options.RequireHttpsMetadata = false; // true in production!
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
Console.WriteLine($"Auth failed: {context.Exception.Message}");
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
var userId = context.Principal?.FindFirst("sub")?.Value;
Console.WriteLine($"Token validated for user: {userId}");
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
// Controllers/ConferencesController.cs — API protected by JWT
[ApiController]
[Route("api/[controller]")]
[Authorize] // Requires a valid JWT Bearer
public class ConferencesController : ControllerBase
{
private readonly IConferenceRepository _repo;
public ConferencesController(IConferenceRepository repo) => _repo = repo;
[HttpGet]
public async Task<IActionResult> GetAll()
{
var userId = User.FindFirst("sub")?.Value;
return Ok(await _repo.GetAll());
}
[HttpPost]
[Authorize(Policy = "CanAddConference")]
public async Task<IActionResult> Create(ConferenceModel model)
{
await _repo.Add(model);
return CreatedAtAction(nameof(GetAll), model);
}
[HttpGet("public")]
[AllowAnonymous]
public IActionResult GetPublic() => Ok("Public data");
}
7.3 Token Validation — TokenValidationParameters
When you don’t have an Identity Provider but create your own JWT tokens (e.g., internal micro-services):
// Program.cs — validation without Identity Provider
var signingKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"]!));
// The key must be at least 16 characters (128 bits), ideally 32+ (256 bits)
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
// Signature validation
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey,
// Issuer validation (your application)
ValidateIssuer = true,
ValidIssuer = "https://your-api.example.com",
// Audience validation (target API)
ValidateAudience = true,
ValidAudience = "https://your-api.example.com",
// Expiration validation
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(5), // Clock tolerance
// Claim type mapping
NameClaimType = "sub",
RoleClaimType = ClaimTypes.Role,
};
});
Supported signing algorithms:
| Algorithm | Type | Description |
|---|---|---|
HS256 | Symmetric | Shared secret (HMAC-SHA256) |
HS512 | Symmetric | Shared secret (HMAC-SHA512) |
RS256 | Asymmetric | RSA private/public key |
RS512 | Asymmetric | RSA 512-bit |
ES256 | Asymmetric | Elliptic Curve (more compact) |
Best practice: use asymmetric algorithms (RS256) for public IdPs. The API only needs the public key to validate the token.
7.4 Creating a JWT Manually (for testing)
// Service/JwtTokenService.cs
public class JwtTokenService
{
private readonly IConfiguration _config;
public JwtTokenService(IConfiguration config) => _config = config;
public string GenerateToken(ApplicationUser user, IList<string> roles)
{
var signingKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["Jwt:SecretKey"]!));
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id),
new Claim(JwtRegisteredClaimNames.Email, user.Email!),
new Claim(JwtRegisteredClaimNames.Name, user.UserName!),
// Jti = JWT ID (unique per token, useful for revocation)
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
// Add roles as claims
foreach (var role in roles)
claims.Add(new Claim(ClaimTypes.Role, role));
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddHours(1), // Short lifetime!
NotBefore = DateTime.UtcNow,
Issuer = _config["Jwt:Issuer"],
Audience = _config["Jwt:Audience"],
SigningCredentials = new SigningCredentials(
signingKey, SecurityAlgorithms.HmacSha256Signature)
};
var tokenHandler = new JwtSecurityTokenHandler();
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
}
// appsettings.json (never hardcode SecretKey in production — use secrets!)
{
"Jwt": {
"SecretKey": "use-secret-manager-for-this-value-32chars+",
"Issuer": "https://your-api.example.com",
"Audience": "https://your-api.example.com"
}
}
// Controllers/AuthController.cs — login endpoint (generates a JWT)
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly JwtTokenService _tokenService;
public AuthController(UserManager<ApplicationUser> userManager, JwtTokenService tokenService)
{
_userManager = userManager;
_tokenService = tokenService;
}
[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginRequest request)
{
var user = await _userManager.FindByEmailAsync(request.Email);
if (user == null || !await _userManager.CheckPasswordAsync(user, request.Password))
return Unauthorized(new { error = "Invalid credentials" });
var roles = await _userManager.GetRolesAsync(user);
var token = _tokenService.GenerateToken(user, roles);
return Ok(new
{
access_token = token,
expires_in = 3600,
token_type = "Bearer"
});
}
}
7.5 Refresh Tokens — Concept and Implementation
Access JWT tokens have a short lifetime (15 min to 1h). Refresh tokens allow getting new ones without the user logging in again.
┌──────────────────────────────────────────────────────────────────┐
│ TOKEN LIFECYCLE │
│ │
│ Login │
│ └─► Access Token (1h) + Refresh Token (30 days) │
│ │ │ │
│ │ Used for APIs │ Stored server-side │
│ │ │ (AspNetUserTokens) │
│ │ │ │
│ 1 hour later... │ │
│ │ │ │
│ 401 Unauthorized │ │
│ │ │ │
│ POST /api/auth/refresh ────────────┘ │
│ { refresh_token: "..." } │
│ │ │
│ New Access Token (1h) + New Refresh Token │
│ (rotation: old refresh token is invalidated) │
└──────────────────────────────────────────────────────────────────┘
// Service/RefreshTokenService.cs
public class RefreshTokenService
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly JwtTokenService _jwtTokenService;
public RefreshTokenService(
UserManager<ApplicationUser> userManager,
JwtTokenService jwtTokenService)
{
_userManager = userManager;
_jwtTokenService = jwtTokenService;
}
public async Task<string> GenerateRefreshToken(ApplicationUser user)
{
// Generate a secure random token
var refreshToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
// Store in AspNetUserTokens
await _userManager.SetAuthenticationTokenAsync(
user,
loginProvider: "LocalApi",
tokenName: "RefreshToken",
tokenValue: refreshToken);
return refreshToken;
}
}
// Controllers/AuthController.cs — refresh endpoint
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{
var user = await _refreshTokenService.ValidateRefreshToken(request.RefreshToken);
if (user == null)
return Unauthorized(new { error = "Invalid or expired refresh token" });
// Rotation: invalidate the old refresh token
await _userManager.RemoveAuthenticationTokenAsync(user, "LocalApi", "RefreshToken");
// Generate new tokens
var roles = await _userManager.GetRolesAsync(user);
var newAccessToken = _jwtTokenService.GenerateToken(user, roles);
var newRefreshToken = await _refreshTokenService.GenerateRefreshToken(user);
return Ok(new
{
access_token = newAccessToken,
refresh_token = newRefreshToken,
expires_in = 3600
});
}
8. OAuth 2.0 — Client Credentials Flow
8.1 Machine-to-Machine Flow
The Client Credentials Flow is used for machine-to-machine authentication between services without a user involved.
┌──────────────────────────────────────────────────────────────────┐
│ CLIENT CREDENTIALS FLOW │
│ │
│ Service A Identity Provider Service B │
│ (client) (API) │
│ │ │ │ │
│ │── POST /connect/token ──►│ │ │
│ │ grant_type=client_credentials │ │
│ │ client_id=service_a │ │
│ │ client_secret=xxx │ │
│ │ scope=service_b_api │ │
│ │ │ │ │
│ │◄── access_token ────────│ │ │
│ │ │ │ │
│ │── GET /api/data ─────────────────────────────►│ │
│ │ Authorization: Bearer <token> │ │
│ │ │ │
│ │◄── 200 OK ────────────────────────────────────│ │
│ │
│ No user involved! The client is the "subject" │
│ No redirect, purely back-channel flow │
└──────────────────────────────────────────────────────────────────┘
8.2 Configuration in IdentityServer
// Config.cs — configure a machine-to-machine client
public static IEnumerable<Client> Clients => new List<Client>
{
// Machine-to-machine client (Client Credentials)
new Client
{
ClientId = "reporting_service",
ClientSecrets = { new Secret("reporting_secret".Sha256()) },
// ONLY Client Credentials for this client
AllowedGrantTypes = GrantTypes.ClientCredentials,
// No identity scopes (no user!)
AllowedScopes = { "globomantics_api" }
}
};
8.3 Consuming a Protected API (HttpClient)
# NuGet packages
dotnet add package IdentityModel
dotnet add package IdentityModel.AspNetCore
// Program.cs — Service A (the client)
// Register an HttpClient with automatic token management
builder.Services.AddClientCredentialsTokenManagement()
.AddClient("reporting", client =>
{
client.TokenEndpoint = "https://localhost:5000/connect/token";
client.ClientId = "reporting_service";
client.ClientSecret = "reporting_secret";
client.Scope = "globomantics_api";
});
// HttpClient that automatically obtains and renews the token
builder.Services.AddClientCredentialsHttpClient("globomantics_api",
configureClient: client =>
{
client.BaseAddress = new Uri("https://api.globomantics.com");
},
tokenClientName: "reporting");
// Service/ReportingService.cs
public class ReportingService
{
private readonly HttpClient _httpClient;
public ReportingService(IHttpClientFactory factory)
{
// This client automatically adds Bearer token in header
_httpClient = factory.CreateClient("globomantics_api");
}
public async Task<IEnumerable<Conference>> GetAllConferences()
{
// Token is obtained and renewed automatically
return await _httpClient.GetFromJsonAsync<IEnumerable<Conference>>(
"api/conferences") ?? [];
}
}
9. Data Protection API
9.1 Role and Architecture
The Data Protection API is the fundamental cryptographic layer of ASP.NET Core. It is used by:
- Cookie authentication (encrypting the identity cookie)
- Anti-forgery tokens
- Identity (email tokens, password reset)
- TempData
- Any other data protection need
┌──────────────────────────────────────────────────────────────────┐
│ DATA PROTECTION API │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ IDataProtector │ │
│ │ │ │
│ │ Protect(byte[] plaintext) → byte[] ciphertext │ │
│ │ Unprotect(byte[] ciphertext) → byte[] plaintext │ │
│ │ │ │
│ │ Linked to a "purpose" string (context separation) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ uses │
│ ┌───────────────────────────▼────────────────────────────────┐ │
│ │ Key Ring │ │
│ │ │ │
│ │ Active key (used for encryption) │ │
│ │ Previous keys (used for decryption only) │ │
│ │ │ │
│ │ Storage: file system, Azure Blob, DB, Redis... │ │
│ │ Key lifetime: 90 days │ │
│ │ Automatic rotation │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
9.2 Advanced Configuration
// Program.cs — Data Protection configuration
builder.Services.AddDataProtection()
// Name the application (important in multi-instance deployments!)
.SetApplicationName("GlobomanticsApp")
// Key lifetime (default: 90 days)
.SetDefaultKeyLifetime(TimeSpan.FromDays(90))
// Key storage in the file system (development)
.PersistKeysToFileSystem(new DirectoryInfo(@"C:\keys\globomantics"))
// OR storage in Azure Blob Storage (production)
// .PersistKeysToAzureBlobStorage(blobClient)
// OR storage in Entity Framework (database)
// .PersistKeysToDbContext<ApplicationDbContext>()
// Key encryption with a certificate (production)
// .ProtectKeysWithCertificate(certificate)
;
IMPORTANT: in a server farm (load balancing), all instances must share the same key ring (Azure Blob, Redis, SQL). Without this, cookies encrypted by one server cannot be decrypted by another!
9.3 Direct Usage
// Using DataProtection in your code
public class SecureTokenService
{
private readonly IDataProtector _protector;
public SecureTokenService(IDataProtectionProvider provider)
{
// The "purpose" isolates this protector from other uses
_protector = provider.CreateProtector("GlobomanticsApp.TokenService");
}
// Protect sensitive data
public string ProtectUserId(string userId)
{
return _protector.Protect(userId);
}
// Decrypt
public string? UnprotectUserId(string protectedId)
{
try
{
return _protector.Unprotect(protectedId);
}
catch (CryptographicException)
{
// Invalid or expired token
return null;
}
}
}
// With expiration (ITimeLimitedDataProtector)
public class TimedTokenService
{
private readonly ITimeLimitedDataProtector _protector;
public TimedTokenService(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("EmailConfirmation")
.ToTimeLimitedDataProtector();
}
public string CreateToken(string email)
{
// Token valid for 24 hours
return _protector.Protect(email, lifetime: TimeSpan.FromHours(24));
}
public string? ValidateToken(string token)
{
try
{
return _protector.Unprotect(token);
}
catch
{
return null; // Expired or invalid
}
}
}
10. Anti-Forgery Tokens and CORS
10.1 Anti-Forgery Tokens (CSRF)
In addition to SameSite cookies, ASP.NET Core provides an anti-forgery token mechanism for two-layer CSRF protection.
How it works
┌──────────────────────────────────────────────────────────────────┐
│ CSRF PROTECTION WITH ANTI-FORGERY TOKEN │
│ │
│ 1. GET request (e.g., /proposals/add) │
│ └─► Server generates 2 linked tokens: │
│ a) Anti-forgery cookie (httpOnly) │
│ b) Token in HTML (hidden field or header) │
│ │
│ 2. POST request (form submit) │
│ └─► Browser sends cookie AUTOMATICALLY (a) │
│ └─► Form sends HTML token MANUALLY (b) │
│ │
│ 3. Server compares (a) and (b) │
│ → Identical: legitimate request ✓ │
│ → Different: potential CSRF request ✗ │
│ │
│ A malicious site can make the cookie sent automatically │
│ BUT cannot know the HTML token (Same-Origin Policy) │
└──────────────────────────────────────────────────────────────────┘
Configuration and usage
// Program.cs — Anti-Forgery is active by default in MVC/Razor Pages
builder.Services.AddAntiforgery(options =>
{
options.Cookie.Name = "XSRF-TOKEN";
options.HeaderName = "X-XSRF-TOKEN";
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
<!-- Razor View / Tag Helpers (automatic in forms) -->
<form asp-action="Approve" method="post">
<!-- Tag Helper automatically adds: -->
<!-- <input name="__RequestVerificationToken" type="hidden" value="..." /> -->
<button type="submit">Approve</button>
</form>
// Controller — automatic validation with [ValidateAntiForgeryToken]
[HttpPost]
[ValidateAntiForgeryToken] // Automatically validates the token
public async Task<IActionResult> Approve(int proposalId)
{
await _repo.Approve(proposalId);
return RedirectToAction("Index");
}
Anti-Forgery for AJAX / SPA requests
// JavaScript / Fetch API — add the token in the header
async function approveProposal(id) {
const token = document.cookie
.split('; ')
.find(row => row.startsWith('XSRF-TOKEN='))
?.split('=')[1];
const response = await fetch(`/api/proposals/${id}/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': token // Anti-forgery header
}
});
return response.json();
}
10.2 CORS Configuration
CORS (Cross-Origin Resource Sharing) controls which sites can call your API from a browser.
// Program.cs — CORS configuration
var frontendOrigin = builder.Configuration["FrontendOrigin"]
?? "https://spa.globomantics.com";
builder.Services.AddCors(options =>
{
// "Restrictive" policy for the API in production
options.AddPolicy("AllowGlobomanticsSpa", policy =>
{
policy
.WithOrigins(frontendOrigin) // Allowed origins (never "*" with credentials!)
.WithMethods("GET", "POST", "PUT", "DELETE", "PATCH")
.WithHeaders("Content-Type", "Authorization", "X-XSRF-TOKEN")
.AllowCredentials() // Allow cookies / credentials
.SetPreflightMaxAge(TimeSpan.FromMinutes(10)); // Cache preflight
});
// Permissive policy for development
options.AddPolicy("AllowAll", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
// Note: AllowAnyOrigin() + AllowCredentials() = IMPOSSIBLE (security)
});
});
// Add CORS in the pipeline (BEFORE UseAuthentication!)
app.UseCors("AllowGlobomanticsSpa");
app.UseAuthentication();
app.UseAuthorization();
Summary of important CORS rules:
| Rule | Reason |
|---|---|
Never use AllowAnyOrigin() with AllowCredentials() | Prohibited by browsers (security) |
| List origins explicitly | Avoid “origin spoofing” attacks |
Put UseCors() before UseAuthentication() | Preflight (OPTIONS) must not be blocked |
| Don’t rely on CORS as the only protection | Server-side, always validate authorization |
11. Review Questions
Q1 — Cookie Authentication
Q: What is the difference between SlidingExpiration = true and SlidingExpiration = false for a persistent cookie with ExpireTimeSpan = 14 days?
A: With SlidingExpiration = true (default), if the user accesses the application when less than 7 days (half of 14) remain before expiration, the ticket is automatically renewed for another 14 days. Thus an active user is never logged out. With SlidingExpiration = false, the ticket expires exactly 14 days after login, regardless of activity.
Q2 — ClaimsPrincipal
Q: Explain the relationship between ClaimsPrincipal, ClaimsIdentity, and Claim. Why can a ClaimsPrincipal have multiple identities?
A: A ClaimsPrincipal represents the user and can have multiple ClaimsIdentity objects (one per authentication scheme). Each ClaimsIdentity contains a list of Claim objects (type/value pairs). Multi-identity allows, for example, having a local Cookie identity AND a Google identity simultaneously. The User.Claims property aggregates claims from all identities.
Q3 — ASP.NET Core Identity
Q: What role does SecurityStamp play in ASP.NET Core Identity? In what scenario is it particularly useful?
A: SecurityStamp is a random value stored in AspNetUsers. It is included in the identity cookie on login. On each request, Identity compares the stamp from the cookie with the one in the database. If an administrator changes a user’s password, locks their account, or changes their email, the SecurityStamp changes server-side. A still-logged-in user will have their cookie rejected on the next request, forcing re-login.
Q4 — OAuth / OIDC
Q: Why is Authorization Code Flow + PKCE more secure than sending tokens directly in the redirect URL?
A: The front channel (browser) is considered insecure — everything transiting through it can be intercepted. By first sending a code (short and disposable) via the front channel, then exchanging this code for tokens via a secure back channel (server-to-server with client_secret), tokens never transit through the browser. PKCE adds an extra layer: even if the code is intercepted, the attacker cannot exchange it without the original code_verifier.
Q5 — JWT
Q: Is a JWT encrypted? What does it contain? What information should never be put in it?
A: A JWT is not encrypted, only Base64 encoded. It is therefore readable by anyone (visible on jwt.io). Its security rests on the signature which guarantees authenticity and integrity, not confidentiality. It contains: a header (algorithm), a payload (claims: sub, iss, exp, aud, etc.) and a signature. Never put in it: passwords, credit card numbers, API secrets, or any data sensitive to only authorized people.
Q6 — Authorization Policies
Q: What is the difference between FallbackPolicy and DefaultPolicy in ASP.NET Core Authorization?
A: DefaultPolicy applies when [Authorize] is present without a named policy — by default, it requires the user to be authenticated. FallbackPolicy applies when no [Authorize] attribute is present on an endpoint — useful for securing the entire application by default (secure-by-default). If FallbackPolicy is set to RequireAuthenticatedUser, all endpoints without [AllowAnonymous] are automatically protected.
Q7 — Resource-Based Authorization
Q: Why can’t we use [Authorize(Policy = "CanEditProposal")] for resource-based authorization?
A: The [Authorize] attribute is evaluated before the action is executed, so before the Proposal is loaded from the database. You cannot pass an object as context to the attribute. Resource-based authorization uses IAuthorizationService.AuthorizeAsync(User, resource, "policy") in the action body, once the resource is available.
Q8 — BFF Pattern
Q: Why is the BFF pattern recommended for SPAs with OpenID Connect, rather than implementing OIDC directly in the SPA?
A: A browser cannot keep secrets. If the SPA implements OIDC directly, the client_secret would be visible in the JavaScript code (F12). Additionally, access tokens stored in localStorage are vulnerable to XSS attacks. With BFF, all OIDC complexity (code exchange, token management, refresh) happens server-side. The SPA communicates with its backend via SameSite=Strict cookies (automatic CSRF protection). Tokens never leave the server.
Q9 — Data Protection API
Q: Why is it crucial to properly configure Data Protection key storage in a multi-instance cloud environment?
A: By default, Data Protection stores its keys in memory or a local folder. In a server farm with multiple instances, each instance would have its own key. A cookie encrypted by instance 1 could not be decrypted by instance 2, making authentication randomly broken depending on which instance processes the request. The solution is to share the key ring via Azure Blob Storage, SQL Server, Redis, or other centralized storage.
Q10 — Claims Transformation
Q: Why must the ClaimsTransformer check if a claim already exists before adding it?
A: IClaimsTransformation.TransformAsync can be called multiple times per request (e.g., by different middlewares or handlers). If we add a claim without checking, we can have duplicates in the claims list, which can cause unexpected behavior when checking with User.FindFirst() or during serialization. The check if (identity.HasClaim(c => c.Type == "careerstarted")) prevents this problem.
Q11 — CORS
Q: Why can’t we combine AllowAnyOrigin() and AllowCredentials() in a CORS policy?
A: This is a security restriction imposed by browsers (CORS spec). If any origin were allowed to send requests with credentials (cookies), it would nullify all the CSRF protection that SameSite cookies and anti-forgery tokens attempt to provide — any malicious site could make authenticated requests on behalf of the user. ASP.NET Core throws an exception if you try to combine these two options.
Q12 — IdentityServer / Scopes
Q: What is the difference between an Identity Scope and an API Scope in OpenID Connect / Duende IdentityServer?
A: An Identity Scope is a “basket of user claims” — e.g., profile contains name, given_name, family_name, picture. It allows the client to request certain information about the user via the identity token or UserInfo endpoint. An API Scope represents access to a specific API — e.g., globomantics_api. Its presence in the access token means the client has the right to call this API. Scopes requested by the client must be listed in AllowedScopes in the client configuration on the IdP.
Q13 — Roles vs Claims
Q: In Identity, how do you enable roles? What is the difference between a UserClaim and a RoleClaim?
A: Roles are disabled by default with AddDefaultIdentity. To enable: AddDefaultIdentity<ApplicationUser>().AddRoles<IdentityRole>(). A UserClaim is directly associated with a user in the AspNetUserClaims table. A RoleClaim is associated with a role in AspNetRoleClaims — when a user is in that role, they automatically inherit all role claims. This distinction allows managing permissions at the role level rather than duplicating them for each user.
Q14 — Refresh Tokens
Q: What is “refresh token rotation” and why is it important?
A: Rotation means that each time a refresh token is used to get a new access token, the refresh token is invalidated and a new one is issued. If an attacker steals a refresh token and uses it, the legitimate holder (who will use the old token later) will receive a rejection. The application can thus detect a stolen token and invalidate the entire session. Without rotation, a stolen refresh token can be used indefinitely until its natural expiration.
12. Advanced Authorization Scenarios
12.1 Resource-Based Authorization — Complete Guide
Resource-based authorization evaluates access rights taking into account the resource involved — for example, a user can edit their own proposals but not others’.
Step 1 — Define the Requirement
// Authorization/ProposalRequirement.cs
public class SpeakerEditRequirement : IAuthorizationRequirement
{
// This requirement contains no data (empty)
// Logic is in the handler
}
// Requirement with data
public class MinimumAgeRequirement : IAuthorizationRequirement
{
public int MinimumAge { get; }
public MinimumAgeRequirement(int minimumAge)
{
MinimumAge = minimumAge;
}
}
Step 2 — Implement the Handler
// Authorization/ProposalAuthorizationHandler.cs
public class ProposalAuthorizationHandler
: AuthorizationHandler<SpeakerEditRequirement, ProposalModel>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
SpeakerEditRequirement requirement,
ProposalModel resource)
{
var userName = context.User.Identity?.Name;
// User can only edit their own proposals
// and only if they haven't been approved yet
if (resource.Speaker == userName && !resource.Approved)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
// Generic handler for multiple resource types
public class AdminBypassHandler : IAuthorizationHandler
{
public Task HandleAsync(AuthorizationHandlerContext context)
{
// Administrators always pass
if (context.User.IsInRole("Admin"))
{
foreach (var requirement in context.PendingRequirements.ToList())
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Step 3 — Register Policy and Handler
// Program.cs
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanEditProposal", policy =>
policy.Requirements.Add(new SpeakerEditRequirement()));
options.AddPolicy("MinimumAge18", policy =>
policy.Requirements.Add(new MinimumAgeRequirement(18)));
});
// Register handlers — SINGLETON (no per-request state)
builder.Services.AddSingleton<IAuthorizationHandler, ProposalAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, AdminBypassHandler>();
Step 4 — Use in the Controller
// Controllers/ProposalsController.cs
[Authorize]
public class ProposalsController : Controller
{
private readonly IProposalRepository _repo;
private readonly IAuthorizationService _authorizationService;
public ProposalsController(
IProposalRepository repo,
IAuthorizationService authorizationService)
{
_repo = repo;
_authorizationService = authorizationService;
}
[HttpGet("{id}/edit")]
public async Task<IActionResult> Edit(int id)
{
var proposal = await _repo.GetById(id);
if (proposal == null)
return NotFound();
// Evaluate the policy with the concrete resource
var authResult = await _authorizationService
.AuthorizeAsync(User, proposal, "CanEditProposal");
if (!authResult.Succeeded)
{
// 403 Forbidden (not 401 Unauthorized — the user is identified)
return Forbid();
}
return View(proposal);
}
}
12.2 Dynamic Policies — Policy Factory
Sometimes you want parameterized policies (e.g., RequireMinimumAge:21, RequireScope:read). The Policy Factory (also called IAuthorizationPolicyProvider) allows generating policies on the fly.
// Authorization/MinimumAgePolicyProvider.cs
public class MinimumAgePolicyProvider : DefaultAuthorizationPolicyProvider
{
private const string PolicyPrefix = "MinimumAge";
public MinimumAgePolicyProvider(IOptions<AuthorizationOptions> options)
: base(options) { }
public override async Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.StartsWith(PolicyPrefix, StringComparison.OrdinalIgnoreCase))
{
var agePart = policyName[PolicyPrefix.Length..];
if (int.TryParse(agePart, out var age))
{
var policy = new AuthorizationPolicyBuilder()
.AddRequirements(new MinimumAgeRequirement(age))
.Build();
return policy;
}
}
return await base.GetPolicyAsync(policyName);
}
}
// Program.cs — register the custom provider
builder.Services.AddSingleton<IAuthorizationPolicyProvider, MinimumAgePolicyProvider>();
// Usage in controllers — "dynamic" policy via attribute
[Authorize(Policy = "MinimumAge18")] // Dynamically generated policy
public IActionResult AdultContent() => View();
[Authorize(Policy = "MinimumAge21")] // Same handler, different age
public IActionResult AlcoholContent() => View();
12.3 Authorization Strategy Comparison
| Strategy | When to Use | Advantages | Limitations |
|---|---|---|---|
Roles [Authorize(Roles="Admin")] | Simple user groups | Intuitive, well-supported | Tight coupling, hard to evolve |
Claims [Authorize(Policy="HasClaim")] | Fine-grained user attributes | Granular, flexible | Possible logic duplication |
| Static policies | Reusable business rules | Centralized, testable | No parameters |
| Dynamic policies | Same rules, variable thresholds | DRY, powerful | More complex to implement |
| Resource-based | Depends on a specific instance | Precise, secure | Not usable with [Authorize] |
| Imperative in code | Unique one-shot logic | Simplicity | Non-reusable, duplication risk |
┌──────────────────────────────────────────────────────────────────┐
│ DECISION TREE — WHICH STRATEGY TO CHOOSE? │
│ │
│ Does the check depend on a loaded data instance? │
│ Yes → Resource-Based Authorization │
│ No ↓ │
│ │
│ Does the rule depend on a numeric/variable parameter? │
│ Yes → Dynamic policy (IAuthorizationPolicyProvider) │
│ No ↓ │
│ │
│ Is the rule used in multiple places? │
│ Yes → Static policy + Requirement + Handler │
│ No ↓ │
│ │
│ Simple group membership? │
│ Yes → Roles │
│ No → Claims policy │
└──────────────────────────────────────────────────────────────────┘
12.4 Multiple Requirements in a Policy
A policy can combine multiple requirements. By default, all must be satisfied (AND logic).
// Program.cs — policy with multiple requirements (AND)
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("SeniorSpeaker", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("email_verified", "true");
policy.Requirements.Add(new YearsOfExperienceRequirement(2));
});
options.AddPolicy("ConferenceOrganizer", policy =>
{
policy.RequireRole("Organizer", "Admin"); // Organizer OR Admin
policy.RequireClaim("conference_id"); // AND must have this claim
});
});
OR logic between requirements
OR logic requires registering multiple handlers for the same requirement. If one of the handlers calls context.Succeed(), the requirement is satisfied.
// Two handlers for the same requirement → one OR the other is sufficient
public class OwnerHandler : AuthorizationHandler<DocumentAccessRequirement, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
DocumentAccessRequirement requirement,
Document resource)
{
if (resource.OwnerId == context.User.FindFirst("sub")?.Value)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
public class SharedWithUserHandler : AuthorizationHandler<DocumentAccessRequirement, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
DocumentAccessRequirement requirement,
Document resource)
{
var userId = context.User.FindFirst("sub")?.Value;
if (resource.SharedWith.Contains(userId ?? ""))
context.Succeed(requirement);
return Task.CompletedTask;
}
}
// Both registered → owner OR shared = access granted
builder.Services.AddSingleton<IAuthorizationHandler, OwnerHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, SharedWithUserHandler>();
12.5 Conditional Display with IAuthorizationService
// Pages/Proposals/Index.cshtml.cs — Razor Page
public class IndexModel : PageModel
{
public IEnumerable<ProposalModel> Proposals { get; private set; } = [];
public Dictionary<int, bool> CanEdit { get; private set; } = new();
public async Task OnGetAsync()
{
Proposals = await _repo.GetAll();
foreach (var proposal in Proposals)
{
var result = await _authorizationService
.AuthorizeAsync(User, proposal, "CanEditProposal");
CanEdit[proposal.Id] = result.Succeeded;
}
}
}
<!-- Pages/Proposals/Index.cshtml -->
@foreach (var proposal in Model.Proposals)
{
<div class="proposal-card">
<h3>@proposal.Title</h3>
<p>Speaker: @proposal.Speaker</p>
@if (Model.CanEdit[proposal.Id])
{
<a asp-page="Edit" asp-route-id="@proposal.Id" class="btn btn-primary">
Edit
</a>
}
@{
var canApprove = await AuthorizationService
.AuthorizeAsync(User, proposal, "CanApproveProposal");
}
@if (canApprove.Succeeded)
{
<form asp-page-handler="Approve" method="post">
<input type="hidden" name="proposalId" value="@proposal.Id" />
<button type="submit" class="btn btn-success">Approve</button>
</form>
}
</div>
}
13. Security Best Practices
13.1 OWASP Top 10 → ASP.NET Core Mapping
The OWASP Top 10 lists the most critical security risks for web applications. Here’s how ASP.NET Core addresses them:
| # | OWASP Risk | Defense in ASP.NET Core |
|---|---|---|
| A01 | Broken Access Control | Authorization Policies, Resource-based Auth, [Authorize] |
| A02 | Cryptographic Failures | Data Protection API, HTTPS enforcement, ProtectKeysWithAzureKeyVault |
| A03 | Injection (SQL, LDAP) | EF Core parameterized (no raw SQL), input validation |
| A04 | Insecure Design | Threat modeling, defense in depth, least privilege principle |
| A05 | Security Misconfiguration | app.UseHsts(), security headers, RequireHttpsMetadata = true |
| A06 | Vulnerable Components | dotnet list package --vulnerable, Dependabot |
| A07 | Auth & Session Failures | ASP.NET Core Identity, SecurityStamp, SlidingExpiration |
| A08 | Software & Data Integrity | Data Protection signatures, signed JWTs, HTTPS |
| A09 | Security Logging | ILogger, Application Insights, alerts on repeated 401/403 |
| A10 | Server-Side Request Forgery | URL validation, HttpClient with validation, whitelist |
A01 — Broken Access Control
// BAD: no control over the requested resource
[HttpGet("{id}")]
public async Task<IActionResult> GetDocument(int id)
{
return Ok(await _repo.Get(id)); // Anyone can access any document!
}
// GOOD: check that the resource belongs to the user
[HttpGet("{id}")]
[Authorize]
public async Task<IActionResult> GetDocument(int id)
{
var document = await _repo.Get(id);
if (document == null) return NotFound();
// Explicit check: IDOR prevention
if (document.OwnerId != User.FindFirst("sub")?.Value)
return Forbid();
return Ok(document);
}
A03 — Injection
// BAD: SQL injection possible
var query = $"SELECT * FROM Users WHERE Name = '{username}'";
// GOOD: EF Core with parameters (automatic protection)
var user = await _context.Users
.Where(u => u.Name == username)
.FirstOrDefaultAsync();
// If raw SQL is needed: FromSqlRaw with parameters
var users = await _context.Users
.FromSqlRaw("SELECT * FROM Users WHERE Name = {0}", username)
.ToListAsync();
13.2 Secrets Management
Never store secrets in source code or in appsettings.json committed to repository.
┌──────────────────────────────────────────────────────────────────┐
│ ASP.NET CORE CONFIGURATION HIERARCHY │
│ (increasing priority bottom to top) │
│ │
│ 5. Command line args │
│ 4. Environment variables ← Production │
│ 3. User Secrets (dotnet user-secrets) ← Development │
│ 2. appsettings.{Environment}.json │
│ 1. appsettings.json ← Default values │
│ │
│ Higher values override lower values. │
└──────────────────────────────────────────────────────────────────┘
User Secrets (local development)
# Initialize secrets for the project (once)
dotnet user-secrets init
# Add secrets
dotnet user-secrets set "Jwt:SecretKey" "my-super-secret-key-32-characters!"
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=.;Database=Globo;..."
dotnet user-secrets set "OidcClientSecret" "client_secret_value"
# List all secrets
dotnet user-secrets list
Production environment variables
# Azure App Service — Environment variables in the portal
# Or via CLI:
az webapp config appsettings set \
--name globomantics-app \
--resource-group rg-globomantics \
--settings "Jwt__SecretKey=production_secret_here"
Azure Key Vault (recommended for production)
// Program.cs
if (!builder.Environment.IsDevelopment())
{
builder.Configuration.AddAzureKeyVault(
new Uri($"https://kv-globomantics.vault.azure.net/"),
new DefaultAzureCredential());
}
13.3 HTTPS and Transport Security
// Program.cs — force HTTPS
var app = builder.Build();
// Redirect HTTP to HTTPS
app.UseHttpsRedirection();
// HSTS (HTTP Strict Transport Security) — only in production
if (!app.Environment.IsDevelopment())
{
app.UseHsts(); // Tells the browser to always use HTTPS for 1 year
}
// Program.cs — HSTS configuration
builder.Services.AddHsts(options =>
{
options.Preload = true;
options.IncludeSubDomains = true;
options.MaxAge = TimeSpan.FromDays(365);
});
// Security headers middleware
app.Use(async (context, next) =>
{
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("X-XSS-Protection", "1; mode=block");
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
context.Response.Headers.Append(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
await next();
});
13.4 Client-Side Token Storage
┌──────────────────────────────────────────────────────────────────┐
│ WHERE TO STORE TOKENS CLIENT-SIDE? │
│ │
│ localStorage │
│ ───────────── │
│ ✗ Vulnerable to XSS attacks │
│ ✗ Accessible by all JS scripts (including injected malicious) │
│ ✗ NEVER for access tokens or sensitive data │
│ │
│ sessionStorage │
│ ────────────── │
│ ✗ Also vulnerable to XSS │
│ ✓ Destroyed when tab closes │
│ ✗ Still not recommended for tokens │
│ │
│ Memory (JS variable) │
│ ────────────────────── │
│ ✓ Inaccessible to XSS (if no eval / dangerouslySetInnerHTML) │
│ ✗ Lost on page refresh │
│ ✓ Acceptable for SPA access tokens (short-lived) │
│ │
│ httpOnly Cookie │
│ ───────────────── │
│ ✓ Inaccessible to JavaScript │
│ ✓ XSS protection │
│ ✓ Recommended for session tokens │
│ → This is the BFF approach │
└──────────────────────────────────────────────────────────────────┘
13.5 Security Checklist (20+ points)
Authentication
- Passwords hashed (bcrypt or Identity’s PasswordHasher)
- Minimum password length: 8+ characters
- Account lockout after N failed attempts
- 2FA available (or required for sensitive actions)
- Email confirmation for new accounts
- Secure password reset flow
- Session invalidation on logout (server + client)
- SecurityStamp invalidation on credential change
Cookies
-
HttpOnly = true(prevents XSS cookie reading) -
Secure = Always(HTTPS only) -
SameSite = LaxorStrict(CSRF protection) - Short lifetime with appropriate SlidingExpiration
-
__Host-prefix for maximum security
Tokens (JWT)
- Short access token lifetime (15 min to 1h)
- Refresh tokens with rotation
- Algorithm RS256 or ES256 (not HS256 for public IdPs)
- Claims validation: iss, aud, exp, nbf
- No sensitive data in payload
- JWKS endpoint for public key verification
HTTPS and Headers
- HTTPS enforced in all environments
- HSTS enabled in production
- Security headers: X-Content-Type-Options, X-Frame-Options, CSP
- Certificate up to date
Secrets
- User Secrets for development
- Environment variables or Key Vault for production
- No secrets in source control
- Regular secret rotation
APIs and CORS
- All endpoints have appropriate authorization
- CORS configured with explicit origins
- No
AllowAnyOrigin()+AllowCredentials() - Anti-forgery tokens for state-changing forms
- Input validation on all endpoints
Authorization
- All sensitive actions protected
- Insecure direct object references (IDOR) prevented
- Authorization at both UI and API level
- Principle of least privilege
14. Review Questions — Part 2
Q15 — OpenID Connect
Q: What is PKCE and why is it now required even for confidential clients?
A: PKCE (Proof Key for Code Exchange) is a security extension of Authorization Code Flow. The client generates a code_verifier (random secret), sends its hash (code_challenge) with the authorization request, then proves it knows the original secret during the token exchange. Even though confidential clients have a client_secret, PKCE adds protection against code interception attacks in TLS channels. OAuth 2.1 makes PKCE mandatory for all clients.
Q16 — Claims Transformation
Q: What is the difference between implementing IClaimsTransformation and using a ProfileService in IdentityServer?
A: IClaimsTransformation in ASP.NET Core is called after the identity is reconstructed from the cookie — it adds claims to the principal before each request (client application side). ProfileService in IdentityServer is called during token issuance — it determines which claims are included in the identity token or access token (IdP side). Both serve to enrich claims, but at different stages of the flow.
Q17 — Architecture
Q: In what scenario would you use both ASP.NET Core Identity AND OpenID Connect in the same solution?
A: In a multi-application architecture: Identity manages users, passwords, 2FA and email confirmation on the IdentityServer (single sign-on server). External applications (web apps, APIs) use OpenID Connect to delegate authentication to this server. Thus, each application doesn’t need to implement its own user management — it trusts the centralized IdP. IdentityServer + Identity = IdP. Client apps + OIDC middleware = consuming applications.
Q18 — Data Protection
Q: What happens if the Data Protection key expires or is rotated?
A: When a key is rotated, the old key remains in the key ring for a grace period (14 days by default). This allows existing cookies to still be decrypted. After this period, old cookies are rejected. The active key (newest) is used for all new encryptions. This rotation is automatic — no action required. However, if the key ring is lost (e.g., Azure deployment without persistent storage), all existing cookies become invalid.
Resources
- Course source code: https://github.com/RolandGuijt/ps-aspnetcoreauth
- Duende IdentityServer: https://duendesoftware.com/products/identityserver
- Secret Manager: https://docs.microsoft.com/aspnet/core/security/app-secrets
- Identity Customization: https://docs.microsoft.com/aspnet/core/security/authentication/identity-custom-storage-providers
- Data Protection ASP.NET Core: https://docs.microsoft.com/aspnet/core/security/data-protection/introduction
- JWT.io: https://jwt.io (JWT token decoding and verification)
- OpenID Connect Spec: https://openid.net/connect/
- PKCE RFC 7636: https://tools.ietf.org/html/rfc7636
- CORS documentation: https://docs.microsoft.com/aspnet/core/security/cors
- Anti-Forgery ASP.NET Core: https://docs.microsoft.com/aspnet/core/security/anti-request-forgery
- IdentityModel (client library): https://identitymodel.readthedocs.io/
- Duende BFF: https://docs.duendesoftware.com/identityserver/v6/bff/
- OAuth 2.0 RFC 6749: https://datatracker.ietf.org/doc/html/rfc6749
Search Terms
asp.net · authentication · authorization · core · security · c# · .net · development · identity · claims · cookie · configuration · tokens · jwt · token · policies · providers · bff · external · flow · identityserver · api · architecture · blazor