30 - Identity
ASP.NET Core Identity is the built-in framework for managing users, passwords, roles, and claims in web applications. It provides a complete system for authentication (verifying who a user is) and authorization (determining what a user is allowed to do). Identity handles everything from password hashing and cookie management to role-based access control, so you don't build these security-critical features from scratch.
This lecture covers Identity for server-rendered (MVC/Razor Pages) applications using cookie-based authentication. Token-based authentication for REST APIs (JWT) is covered in lecture 31.
Authentication vs Authorization
These two concepts are often confused but are fundamentally different:
- Authentication - "Who are you?" - The process of verifying a user's identity. The user provides credentials (email + password), and the system checks if they are valid. Result: the system knows who is making the request.
- Authorization - "What are you allowed to do?" - The process of determining whether an authenticated user has permission to access a specific resource or perform a specific action. Result: access granted or denied.
Authentication always happens first. You cannot authorize someone whose identity you haven't verified.
In ASP.NET Core, these are handled by separate middleware components that must be registered in the correct order (see Pipeline section below).
Claims-based identity model
ASP.NET Core Identity is built on a claims-based model. Understanding this is essential for understanding how [Authorize], User.IsInRole(), and all other Identity features work under the hood.
What is a claim?
A claim is a key-value pair that describes something about a user. For example:
ClaimTypes.Email="user@example.com"ClaimTypes.Role="Admin"ClaimTypes.GivenName="John"
The claims hierarchy:
Claim- a single key-value statement about a userClaimsIdentity- a collection of claims from one source (e.g., cookie, JWT token). Has anAuthenticationTypeproperty and theIsAuthenticatedproperty.ClaimsPrincipal- represents the user. Contains one or moreClaimsIdentityobjects. This is whatHttpContext.User(or simplyUserin controllers) returns.
ClaimsPrincipal (HttpContext.User)
└── ClaimsIdentity (AuthenticationType = "Cookies")
├── Claim: Email = "user@example.com"
├── Claim: Role = "Admin"
├── Claim: GivenName = "John"
└── Claim: Surname = "Doe"
Why this matters:
When you call User.IsInRole("Admin"), it does NOT query the database. It checks if the ClaimsPrincipal contains a claim of type Role with value "Admin". These claims were loaded into the cookie at login time. This is why role changes don't take effect until the user logs out and back in.
Project setup choices
When creating a new ASP.NET Core project, you can choose from several authentication options:
- No Authentication - no authentication/authorization support added
- Individual User Accounts - login info stored in app database, supports external providers (Google, Microsoft, etc.)
- Work or School Accounts - Azure Active Directory / Entra ID
- Windows Authentication - for intranet apps using Windows domain credentials
For most web applications, Individual User Accounts is the appropriate choice.
Identity configuration - password and lockout
Configure Identity options in Program.cs:
builder.Services.Configure<IdentityOptions>(options =>
{
// Password settings
options.Password.RequireDigit = true;
options.Password.RequiredLength = 8;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = true;
options.Password.RequireLowercase = false;
options.Password.RequiredUniqueChars = 6;
// Lockout settings
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30);
options.Lockout.MaxFailedAccessAttempts = 10;
options.Lockout.AllowedForNewUsers = true;
// User settings
options.User.RequireUniqueEmail = true;
});
Password hashing: Identity never stores passwords in plain text. Passwords are hashed using PBKDF2 with HMAC-SHA256/SHA512 by default. When a user logs in, the submitted password is hashed with the same algorithm and compared to the stored hash. The password configuration above only controls validation rules for new passwords, not the hashing mechanism.
Identity configuration - cookie
builder.Services.ConfigureApplicationCookie(options =>
{
// Cookie settings
options.Cookie.HttpOnly = true;
options.ExpireTimeSpan = TimeSpan.FromMinutes(30);
options.LoginPath = "/Account/Login";
options.AccessDeniedPath = "/Account/AccessDenied";
options.SlidingExpiration = true;
});
HttpOnly = true- cookie cannot be accessed by JavaScript (XSS protection)SlidingExpiration = true- cookie expiration is renewed on each request (user stays logged in while active)
Identity middleware and pipeline ordering
Add authentication and authorization middleware in Program.cs. The order matters:
app.UseRouting();
app.UseAuthentication(); // Must come first - reads cookie, sets HttpContext.User
app.UseAuthorization(); // Must come after authentication - checks [Authorize] attributes
app.MapControllers();
app.MapRazorPages();
UseAuthentication() reads the authentication cookie from the request, deserializes the claims, and populates HttpContext.User. UseAuthorization() then uses HttpContext.User to enforce [Authorize] attributes. If you reverse them, authorization will always see an anonymous user.
See also: lecture 04 (Pipeline) for the full middleware ordering.
Cookie login flow
Understanding this flow is essential. Here is what happens step by step when a user logs in via the Identity UI:
- User submits credentials - the login form POSTs email and password to the Identity login Razor Page
- Password verification -
SignInManager.PasswordSignInAsync()retrieves the stored password hash and compares it with the hash of the submitted password - Claims assembly - on success, claims are assembled from the
AspNetUsers,AspNetUserClaims, andAspNetUserRolestables - Cookie creation - the claims are serialized and encrypted into an authentication cookie, which is sent to the browser via
Set-Cookieheader - Subsequent requests - on every subsequent request, the authentication middleware reads the cookie, decrypts and deserializes the claims, and sets
HttpContext.Useras aClaimsPrincipal - Authorization checks -
[Authorize]attributes andUser.IsInRole()calls read from thisClaimsPrincipal(not from the database)
This means: after login, the database is NOT queried on every request to check the user's roles. The claims live in the cookie. Role changes only take effect after the user logs out and back in (or the cookie expires).
Using Identity in code
Common methods
// In controllers, User is HttpContext.User (a ClaimsPrincipal)
User.Identity?.IsAuthenticated // bool property - is the user logged in?
User.IsInRole("Admin") // checks role claim in the ClaimsPrincipal
// Injected via DI
UserManager<AppUser>.GetUserName(User) // get username from claims
UserManager<AppUser>.GetUserAsync(User) // load full user entity from DB
SignInManager<AppUser>.IsSignedIn(User) // checks if user has authenticated identity
Authorization attributes
[Authorize]- only authenticated users can access (redirects to login page)[AllowAnonymous]- always overrides[Authorize], even when applied at controller level[Authorize(Roles = "Admin, Sales")]- roles in a single list are OR-ed (user needs Admin OR Sales)- Multiple attributes are AND-ed:
[Authorize(Roles = "Admin")]
[Authorize(Roles = "Sales")]
public IActionResult SecretReport() // user must have BOTH Admin AND Sales roles
Identity database schema
When your DbContext derives from IdentityDbContext, EF Core migrations will create these tables:
| Table | Purpose |
|---|---|
AspNetUsers | User accounts (email, password hash, lockout info, etc.) |
AspNetRoles | Role definitions (name, normalized name) |
AspNetUserRoles | Many-to-many: which users have which roles |
AspNetUserClaims | Additional claims stored per user |
AspNetRoleClaims | Claims associated with roles |
AspNetUserLogins | External login provider info (Google, etc.) |
AspNetUserTokens | Tokens for password reset, email confirmation, 2FA, etc. |
When you extend IdentityUser with custom properties (e.g., FirstName, LastName), those become additional columns in the AspNetUsers table.
Extending Identity - default types
- Default Identity uses
IdentityUserandIdentityRolewithstringas the primary key type (stores a GUID as a string value). - Default UI for Identity is based on Razor Pages, compiled into a class library (
Microsoft.AspNetCore.Identity.UI) - not directly visible in your code.
Extending Identity - custom AppUser and AppRole
Create your own user and role entities in the Domain layer. PK type can be Guid, int, or string.
namespace Domain.Identity
{
public class AppUser : IdentityUser<Guid>
{
// Add custom properties
public string FirstName { get; set; } = default!;
public string LastName { get; set; } = default!;
// Add navigation properties for your domain entities
}
public class AppRole : IdentityRole<Guid>
{
// Add custom properties if needed
}
}
Extending Identity - AppDbContext
Derive your app DbContext from IdentityDbContext instead of plain DbContext:
namespace DAL.App.EF
{
public class AppDbContext : IdentityDbContext<AppUser, AppRole, Guid>
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}
// Your application DbSets
public DbSet<FooBar> FooBars { get; set; } = default!;
// ...
}
}
Configuring startup with custom types
Register Identity with your custom AppUser and AppRole in Program.cs:
builder.Services.AddIdentity<AppUser, AppRole>(
options => options.SignIn.RequireConfirmedAccount = false)
.AddDefaultUI()
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
When using custom AppUser, you must also update the _LoginPartial.cshtml view to inject the correct types:
<!-- /Views/Shared/_LoginPartial.cshtml -->
@inject SignInManager<AppUser> SignInManager
@inject UserManager<AppUser> UserManager
If these still reference the default IdentityUser, you will get runtime errors.
Scaffolding custom Identity UI
To customize Identity Razor Pages (login, register, manage, etc.), scaffold them into your Web project:
-
Install the code generator tool:
dotnet tool install -g dotnet-aspnet-codegenerator -
From inside the Web project directory, run:
dotnet aspnet-codegenerator identity -dc DAL.App.EF.AppDbContext -f -
The DbContext name is case-sensitive. If there is a mismatch, the generator will create a new DbContext and duplicate startup configuration instead of using your existing one.
After scaffolding, you have full control over all Identity pages (login, register, manage profile, etc.) in your Web project under Areas/Identity/Pages/.
Role management
There is no built-in UI for managing roles - you must implement your own.
Use RoleManager<AppRole> (injected via DI) to create and manage roles:
public class RolesController : Controller
{
private readonly RoleManager<AppRole> _roleManager;
private readonly UserManager<AppUser> _userManager;
public RolesController(RoleManager<AppRole> roleManager, UserManager<AppUser> userManager)
{
_roleManager = roleManager;
_userManager = userManager;
}
// Create a role
await _roleManager.CreateAsync(new AppRole { Name = "Admin" });
// Assign role to user
await _userManager.AddToRoleAsync(user, "Admin");
// Check if user is in role
await _userManager.IsInRoleAsync(user, "Admin");
// Remove role from user
await _userManager.RemoveFromRoleAsync(user, "Admin");
}
Seeding roles and admin user
For development and testing, seed default roles and an admin user at application startup in Program.cs:
// After app.Build() but before app.Run()
using var scope = app.Services.CreateScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<AppUser>>();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<AppRole>>();
// Create roles
if (!await roleManager.RoleExistsAsync("Admin"))
{
await roleManager.CreateAsync(new AppRole { Name = "Admin" });
}
// Create admin user
var adminUser = await userManager.FindByEmailAsync("admin@example.com");
if (adminUser == null)
{
adminUser = new AppUser
{
Email = "admin@example.com",
UserName = "admin@example.com",
FirstName = "Admin",
LastName = "User"
};
await userManager.CreateAsync(adminUser, "Admin.123456");
await userManager.AddToRoleAsync(adminUser, "Admin");
}
Security - resource-based authorization (IDOR)
Identity and [Authorize] attributes control who can access an endpoint, but they do NOT control which specific resources a user can access.
Example: if user A is authenticated and accesses /foobars/1 (their own item), they might also try /foobars/27 (someone else's item). The [Authorize] attribute only checks "is the user logged in?" - it does not check ownership.
This is called IDOR (Insecure Direct Object Reference) and you must handle it explicitly:
[Authorize]
public async Task<IActionResult> Edit(Guid id)
{
var fooBar = await _context.FooBars.FindAsync(id);
if (fooBar == null) return NotFound();
// Check ownership - the critical step!
if (fooBar.AppUserId != User.GetUserId())
{
return Forbid();
}
return View(fooBar);
}
This ownership check belongs in your service/BLL layer in a properly layered architecture, not in the controller directly.
Policy-based authorization
For more complex authorization rules, use policies instead of (or in addition to) role checks:
// In Program.cs
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"));
options.AddPolicy("AtLeast18", policy =>
policy.RequireClaim("DateOfBirth") // custom claim-based policy
);
});
// In controllers
[Authorize(Policy = "AdminOnly")]
public IActionResult AdminDashboard() { ... }
Policies are the recommended approach for non-trivial authorization rules. They encapsulate the authorization logic in one place rather than scattering role names across [Authorize] attributes.
Identity in the architecture
Where Identity components live in a layered architecture:
| Layer | Identity components |
|---|---|
| Domain | AppUser, AppRole (extend IdentityUser, IdentityRole) |
| DAL | AppDbContext (extends IdentityDbContext), migrations |
| BLL / Services | Business authorization logic (ownership checks, complex rules) |
| Web | [Authorize] attributes, _LoginPartial.cshtml, scaffolded Identity UI, DI of UserManager / SignInManager / RoleManager |
UserManager, SignInManager, and RoleManager are registered in DI by AddIdentity<>() and can be injected in controllers and services (see lecture 22 - DI).
External authentication providers
Identity supports external login providers out of the box. After installing the relevant NuGet package, registration is a single line:
builder.Services.AddAuthentication()
.AddGoogle(options => { ... })
.AddMicrosoftAccount(options => { ... });
The scaffolded Identity UI includes pages for managing external logins. The AspNetUserLogins table stores the link between your local user account and the external provider.
Self preparation QA
Be prepared to explain topics like these:
- What is the difference between authentication and authorization? — Authentication verifies who a user is. Authorization determines what they are allowed to do. Authentication always happens first.
- How does the claims-based identity model work? — A Claim is a key-value pair. ClaimsIdentity is a collection of claims from one source. ClaimsPrincipal holds one or more identities. Role changes take effect only after re-login because claims live in the cookie.
- Why does Identity use password hashing, not encryption? — Hashing is one-way. Even if the database is compromised, attackers cannot recover passwords. Identity uses PBKDF2 with HMAC-SHA256/SHA512.
- Why set
HttpOnly = trueon the authentication cookie? — HttpOnly cookies cannot be accessed by JavaScript, protecting against XSS attacks where malicious scripts steal session cookies. - What is IDOR and how do you prevent it? — Insecure Direct Object Reference means a user accesses resources belonging to another user by manipulating IDs.
[Authorize]only checks authentication, not ownership. You must check resource ownership explicitly. - Why use policies instead of scattering role names across
[Authorize]attributes? — Policies encapsulate authorization logic in one place. They support complex rules and are easier to audit and maintain. - Why does the middleware order
UseAuthentication()beforeUseAuthorization()? — Authentication populatesHttpContext.User. Authorization checksUseragainst attributes. If reversed, authorization always sees an anonymous user.