Custom user management in ASP.NET Core is the layer you build on top of Identity to manage users after they sign up: a user model with your own fields, admin endpoints to list, search, role-manage, lock, and delete users, and self-service endpoints so a user can edit their own profile. Identity stores and secures users. This article is about actually managing them, over a clean Web API.
In the Identity API endpoints article I showed the fast path: one MapIdentityApi() call and you get register, login, and refresh for free. That is great until you hit its walls - you cannot add a FirstName at registration, you cannot list your users, and there is no admin surface to manage roles or lock an account. This is the article that builds all of that. Everything runs on a single .NET 10 Minimal API you can clone and hit F5 on. Let’s get into it.
Why Custom User Management (and Where MapIdentityApi Stops)
ASP.NET Core Identity is the framework that stores users, hashes passwords, and enforces roles and lockout. MapIdentityApi is one way to expose it - a fixed set of ten endpoints. The problem is that the set is closed. You cannot extend the /register body, you cannot rename or remove a route, and there is no endpoint to list users or manage them as an admin. These limits are well documented in open issues on the dotnet/aspnetcore repo (#50303 and #47288).
So the moment a real app needs custom fields at sign-up, an admin screen, or role management, you stop calling MapIdentityApi and write your own endpoints. You keep everything valuable about Identity - the user store, password hashing, lockout, UserManager, RoleManager - and you own the HTTP surface on top. That is what “custom user management” means, and it is less code than it sounds.
Here is what this article builds:
| Group | Endpoint | Purpose |
|---|---|---|
| Auth | POST /auth/register | Register a user with custom fields |
| Auth | POST /auth/login | Log in, get a signed JWT |
| Auth | POST /auth/forgot-password POST /auth/reset-password | Password reset flow |
| Auth | POST /auth/confirm-email | Confirm an email address |
| Admin | GET /api/admin/users | List users (paged, searchable) with roles |
| Admin | GET /api/admin/users/{id} | Get one user |
| Admin | PUT / DELETE /api/admin/users/{id} | Update or delete a user |
| Admin | POST / DELETE /api/admin/users/{id}/roles | Assign or remove a role |
| Admin | POST /api/admin/users/{id}/lock .../unlock | Lock or unlock (security lockout) |
| Admin | POST /api/admin/users/{id}/disable .../enable | Disable or enable (admin switch) |
| Self | GET / PUT /api/me | Read or update my own profile |
| Self | POST /api/me/change-password | Change my own password |
Project Setup
Start with a .NET 10 Web API. The one important choice is AddIdentity, not AddIdentityApiEndpoints - that keeps the full Identity engine but leaves the endpoints to you. An in-memory store keeps the demo F5-ready; swap it for SQL Server or SQLite for real use.
builder.Services.AddDbContext<AppDbContext>(options => options.UseInMemoryDatabase("CustomUserManagementDb"));
builder.Services .AddIdentity<ApplicationUser, IdentityRole>(options => { options.User.RequireUniqueEmail = true; options.Password.RequiredLength = 6; options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); }) .AddEntityFrameworkStores<AppDbContext>() .AddDefaultTokenProviders();AddDefaultTokenProviders() is what makes email confirmation and password reset tokens work later, so do not skip it. Login uses a JWT bearer scheme (the same setup from the JWT authentication guide), and the admin group checks the role claim in that token.
The project is small and split by responsibility:
Extending IdentityUser With Your Own Fields
This is the first thing MapIdentityApi cannot do. IdentityUser ships with Id, Email, UserName, PasswordHash, and the lockout columns. To add your own fields, you inherit from it, which is the approach Microsoft documents for adding custom user data to Identity:
public class ApplicationUser : IdentityUser{ public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public string? ProfilePictureUrl { get; set; } public bool IsActive { get; set; } = true; public DateTimeOffset CreatedOn { get; set; } = DateTimeOffset.UtcNow;}Point the DbContext at your custom user and the extra columns land in the AspNetUsers table automatically:
public class AppDbContext(DbContextOptions<AppDbContext> options) : IdentityDbContext<ApplicationUser>(options);With a real database you would run dotnet ef migrations add InitialIdentity here. That is the entire cost of a custom user model: one class and one generic parameter.
Seeding Roles and a Super-Admin
You need at least one admin to exist before anyone can call the admin endpoints. Seed the roles and a super-admin at startup. Order matters - the role has to exist before you assign it:
foreach (var role in new[] { "Admin", "User" }){ if (!await roleManager.RoleExistsAsync(role)) await roleManager.CreateAsync(new IdentityRole(role));}
if (await userManager.FindByEmailAsync("admin@codewithmukesh.com") is null){ var admin = new ApplicationUser { UserName = "admin@codewithmukesh.com", Email = "admin@codewithmukesh.com", FirstName = "Super", LastName = "Admin", EmailConfirmed = true }; await userManager.CreateAsync(admin, "Admin123!"); await userManager.AddToRoleAsync(admin, "Admin");}UserManager<ApplicationUser> and RoleManager<IdentityRole> are the two services you will use everywhere. UserManager handles users - create, find, update, roles, lockout, tokens. RoleManager handles roles. They are the engine behind every endpoint below.
Custom Registration With Extra Fields
Now the register endpoint MapIdentityApi would not let you write. It accepts the extra fields, creates the user, drops them into the default User role, and kicks off email confirmation:
group.MapPost("/register", async ( RegisterRequest request, UserManager<ApplicationUser> userManager, IEmailSender emailSender) =>{ var user = new ApplicationUser { UserName = request.Email, Email = request.Email, FirstName = request.FirstName, LastName = request.LastName };
var result = await userManager.CreateAsync(user, request.Password); if (!result.Succeeded) return Results.ValidationProblem(result.ToProblemDictionary());
await userManager.AddToRoleAsync(user, "User");
var token = await userManager.GenerateEmailConfirmationTokenAsync(user); await emailSender.SendAsync(user.Email, "Confirm your email", $"Confirm with this token: {token}");
var roles = await userManager.GetRolesAsync(user); return Results.Created($"/api/admin/users/{user.Id}", user.ToResponse(roles));});When CreateAsync fails - a weak password, a duplicate email - Identity tells you exactly which rule broke. Passing that through Results.ValidationProblem returns it as an RFC 9457 Problem Details response, the same error shape the rest of your API uses.
Never Return IdentityUser Directly
Notice user.ToResponse(roles) instead of returning user. ApplicationUser carries PasswordHash, security stamps, and lockout internals - none of which belong on the wire. Map to a response record you control:
public record UserResponse( string Id, string Email, string FirstName, string LastName, string? ProfilePictureUrl, bool IsActive, bool IsLockedOut, DateTimeOffset CreatedOn, IEnumerable<string> Roles);This one habit - a response DTO, never the entity - is the difference between an API that is safe to expose and one that leaks its user table.
The Admin Endpoints
This is the part no tutorial gives you as a clean Web API, because almost every “user management” guide is built for MVC and Razor pages. Here it is a set of REST endpoints, and the whole group is admin-only:
var group = app.MapGroup("/api/admin/users") .WithTags("Admin - Users") .RequireAuthorization(policy => policy.RequireRole("Admin"));RequireRole("Admin") on the group means every route under it needs a token with the Admin role. One line, and the whole surface is locked down. For deeper access rules, this is where role-based and policy-based authorization come in.
List Users With Their Roles, Paged and Searchable
The endpoint people rebuild from forum snippets over and over. UserManager.Users is an IQueryable, so you can search, count, and page it like any EF Core query, then attach each user’s roles:
group.MapGet("/", async ( UserManager<ApplicationUser> userManager, CancellationToken cancellationToken, int page = 1, int pageSize = 10, string? search = null) =>{ // Cap the page size at 100 so a client can never pull the whole table in one call. pageSize = pageSize is < 1 or > 100 ? 10 : pageSize;
var query = userManager.Users.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search)) { var term = search.Trim(); query = query.Where(u => u.Email!.Contains(term) || u.FirstName.Contains(term) || u.LastName.Contains(term)); }
var totalCount = await query.CountAsync(cancellationToken); var users = await query .OrderBy(u => u.Email) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken);
var items = new List<UserResponse>(); foreach (var user in users) { var roles = await userManager.GetRolesAsync(user); items.Add(user.ToResponse(roles)); }
return Results.Ok(new PagedResult<UserResponse>(items, page, pageSize, totalCount));});A call to GET /api/admin/users?page=1&pageSize=10&search=jane returns a paged envelope with each user and their roles in one payload. The default page size is 10 and the endpoint caps it at 100, so a client can never ask for the entire user table in a single request. The PagedResult<T> record just wraps the items with page, pageSize, and totalCount so the client can render pagination.
Assign and Remove Roles Over HTTP
Role management is two small endpoints. Check the role exists, then let UserManager do the work:
group.MapPost("/{id}/roles", async ( string id, AssignRoleRequest request, UserManager<ApplicationUser> userManager, RoleManager<IdentityRole> roleManager) =>{ var user = await userManager.FindByIdAsync(id); if (user is null) return Results.NotFound();
if (!await roleManager.RoleExistsAsync(request.Role)) return Results.BadRequest($"Role '{request.Role}' does not exist.");
await userManager.AddToRoleAsync(user, request.Role); return Results.NoContent();});
group.MapDelete("/{id}/roles/{role}", async ( string id, string role, UserManager<ApplicationUser> userManager) =>{ var user = await userManager.FindByIdAsync(id); if (user is null) return Results.NotFound();
await userManager.RemoveFromRoleAsync(user, role); return Results.NoContent();});That is POST /api/admin/users/{id}/roles with { "role": "Admin" } to promote someone, and DELETE /api/admin/users/{id}/roles/Admin to demote them.
Lock vs Disable: Two Different Ways to Block a User
This trips a lot of people up, so it is worth being precise. There are two separate ways to stop a user from logging in, and they are not the same thing:
| Lockout | Disable (IsActive) | |
|---|---|---|
| What it is | Identity’s built-in security feature | Your own custom flag |
| Trigger | Too many failed logins, or an admin lock | An admin decision |
| Duration | Temporary (auto-expires) | Stays off until an admin turns it back on |
| Use it for | Slowing down brute-force attempts | Suspending an account, offboarding |
Lockout is Identity’s response to failed passwords. You expose it with SetLockoutEndDateAsync:
group.MapPost("/{id}/lock", async (string id, UserManager<ApplicationUser> userManager) =>{ var user = await userManager.FindByIdAsync(id); if (user is null) return Results.NotFound();
await userManager.SetLockoutEnabledAsync(user, true); await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)); return Results.NoContent();});Disable is a permanent admin switch - the IsActive field on ApplicationUser. It is not part of Identity, so you enforce it yourself in the login endpoint:
var user = await userManager.FindByEmailAsync(request.Email);if (user is null || !user.IsActive) return Results.Unauthorized();Reach for lockout when you are defending against attacks. Reach for disable when a human decides an account should be switched off. The rest of the admin group - GET one user, PUT to update, DELETE, and enable/disable - follows the same FindByIdAsync then act pattern, and it is all in the repo.
Self-Service Profile Endpoints
Not everything is an admin job. A logged-in user should manage their own account. The key rule: the user id comes from the JWT, never from the request body, so a user can only ever touch their own record.
var group = app.MapGroup("/api/me").WithTags("My Profile").RequireAuthorization();
group.MapGet("/", async (ClaimsPrincipal principal, UserManager<ApplicationUser> userManager) =>{ var user = await GetCurrentUserAsync(principal, userManager); if (user is null) return Results.Unauthorized();
var roles = await userManager.GetRolesAsync(user); return Results.Ok(user.ToResponse(roles));});GetCurrentUserAsync reads the sub claim out of the token and loads that user - that is how an endpoint knows who is calling without trusting anything the client sends. Changing a password is a one-liner because ChangePasswordAsync checks the current password for you:
var result = await userManager.ChangePasswordAsync( user, request.CurrentPassword, request.NewPassword);
return result.Succeeded ? Results.NoContent() : Results.ValidationProblem(result.ToProblemDictionary());Email Confirmation and Password Reset
Identity generates the secure tokens; you decide how to deliver them. That delivery is a seam - an IEmailSender you implement with SendGrid, Amazon SES, or SMTP in production. In the sample it logs the token so the flow runs with zero setup.
Password reset is two endpoints. forgot-password generates a token and always returns 204, even for an unknown email, so the endpoint cannot be used to discover which addresses are registered:
var user = await userManager.FindByEmailAsync(request.Email);if (user is not null){ var token = await userManager.GeneratePasswordResetTokenAsync(user); await emailSender.SendAsync(user.Email!, "Reset your password", $"Reset with this token: {token}");}return Results.NoContent();Then reset-password takes the email, the token, and the new password and calls ResetPasswordAsync. Email confirmation works the same way with GenerateEmailConfirmationTokenAsync and ConfirmEmailAsync.
When to Build This vs Use a Provider
Custom user management is the right call for a huge number of apps, but not all of them. Here is the honest comparison:
| Concern | MapIdentityApi | Custom User Management (this article) | External Provider (Entra, Auth0, Keycloak) |
|---|---|---|---|
| Setup effort | Lowest (one line) | Medium (you write the endpoints) | Medium-high (stand up / configure a provider) |
| Custom user fields | No | Yes | Yes |
| Admin user management | No | Yes, fully yours | Yes, in their dashboard |
| Role management API | No | Yes | Yes |
| Social / SSO login | No | No | Yes, built-in |
| Who owns the user data | You | You | The provider |
| Best for | First-party MVP, one app | You own users + need admin control, no SSO | Many apps, SSO, social login, enterprise |
My Take
Build custom user management when you own the users and need real control over them - custom fields, an admin panel, role management - but you do not need single sign-on or “log in with Google.” That covers most line-of-business APIs and SaaS backends. It is your data, your endpoints, and there is no per-active-user bill.
Reach for an external provider the moment social login, SSO across several apps, or enterprise identity (SAML, SCIM) shows up on the roadmap. Rebuilding federated identity by hand is a genuine time sink, and that is exactly what a provider is good at. The line is simple: if identity is your app’s problem, build this; if identity spans many apps or organizations, buy it.
Troubleshooting
RequireRole("Admin")returns 403 even for an admin. The role claim type in the token must match. SetRoleClaimType = "role"in your JWT options if your token usesrole, or the check silently fails.- A newly registered user cannot be found by role. Roles are assigned after
CreateAsync. If seeding, make sure the role exists beforeAddToRoleAsync, or it throws. - Password reset token is always invalid. You skipped
AddDefaultTokenProviders(). Without it,GeneratePasswordResetTokenAsynchas no provider to sign the token. - Locking a user does nothing.
SetLockoutEndDateAsynconly takes effect if lockout is enabled for that user - callSetLockoutEnabledAsync(user, true)too. - 401 vs 403 confusion. No token or an invalid token gives
401(not authenticated). A valid token without theAdminrole gives403(authenticated, not allowed). - Disabled user can still log in.
IsActiveis your own flag - Identity does not know about it. You must check it in the login endpoint yourself.
Key Takeaways
- Custom user management means keeping ASP.NET Core Identity and writing your own endpoints instead of
MapIdentityApi, so you control the user model and the whole HTTP surface. - Extend
IdentityUserto add fields likeFirstNameandIsActive- one class, and the columns land inAspNetUsersautomatically. UserManagerandRoleManagerare the engine - every admin action (list, assign role, lock, delete) is a few lines on top of them.- Lockout and disable are different - lockout is Identity’s temporary security response; disable (
IsActive) is your permanent admin switch, enforced in the login endpoint. - Always return a response DTO, never the Identity user - the entity carries the password hash and security internals.
FAQ
How do I get a list of all users in an ASP.NET Core Web API?
Inject UserManager and query its Users property, which is an IQueryable. You can filter, order, and page it like any EF Core query, then call GetRolesAsync per user if you want roles in the response. Wrap the result in a paged envelope with page, pageSize, and totalCount. Always project to a response DTO instead of returning the Identity user directly.
How do I get users with their roles in one API call?
Load the page of users from UserManager.Users, then call UserManager.GetRolesAsync for each one and attach the roles to your response DTO. For large pages you can instead join AspNetUserRoles and AspNetRoles in a single query, but the per-user GetRolesAsync approach is the clearest and is fine for normal page sizes.
How do I assign or remove a role from a user via an endpoint?
Use UserManager.AddToRoleAsync(user, role) to assign and UserManager.RemoveFromRoleAsync(user, role) to remove. Expose them as POST /api/admin/users/{id}/roles and DELETE /api/admin/users/{id}/roles/{role}. Check that the role exists with RoleManager.RoleExistsAsync first, and protect the endpoints with an admin authorization policy.
What is the difference between locking and disabling a user in ASP.NET Core Identity?
Locking is Identity's built-in lockout - a temporary block, usually triggered by too many failed logins, set with SetLockoutEndDateAsync. Disabling is a custom flag you add yourself, such as an IsActive property, that stays off until an admin turns it back on. Lockout defends against brute-force attacks; disable is for suspending or offboarding an account, and you must enforce it in your login endpoint.
Can I add custom fields like FirstName to a registered user?
Yes. Create a class that inherits from IdentityUser, add your properties, and use IdentityDbContext of that type. You cannot capture those fields through MapIdentityApi's fixed register endpoint, so write your own registration endpoint that accepts them and calls UserManager.CreateAsync. The extra columns are added to the AspNetUsers table by a migration.
How do I delete a user in ASP.NET Core Identity?
Find the user with UserManager.FindByIdAsync, then call UserManager.DeleteAsync(user). Expose it as a DELETE /api/admin/users/{id} endpoint restricted to admins. If you need to keep history or references, prefer disabling the account with a custom IsActive flag instead of a hard delete.
How do I let a user update their own profile and password?
Add endpoints under a route like /api/me that require authentication and read the user id from the JWT rather than the request body, so a user can only edit their own account. Update the fields on the user and call UserManager.UpdateAsync. For passwords, UserManager.ChangePasswordAsync verifies the current password before setting the new one.
Should I use MapIdentityApi or build custom user-management endpoints?
Use MapIdentityApi for a first-party SPA or mobile MVP where the default email-and-password user is enough and you just need register, login, and refresh fast. Build custom endpoints when you need custom user fields, an admin surface to list and manage users, role management, or full control over the auth flow. You keep ASP.NET Core Identity underneath either way.
Related Reading
Identity API Endpoints in ASP.NET Core
The fast path this article extends - MapIdentityApi's register/login/refresh, and exactly where its walls are.
JWT Authentication in ASP.NET Core
How login issues the signed token the admin endpoints check for the Admin role.
Refresh Tokens in ASP.NET Core
Add long-lived sessions on top of the JWT login used here.
Role-Based Authorization in ASP.NET Core
The next step after managing roles - using them to protect endpoints.
Claims-Based Authorization in ASP.NET Core
Finer-grained access control when roles alone are not enough.
Policy-Based Authorization in ASP.NET Core
Compose complex access rules for your admin endpoints.
Problem Details in ASP.NET Core
The RFC 9457 error format the registration and reset endpoints return on failure.
Minimal APIs in ASP.NET Core
The endpoint style every route in this article is built on.
Summary
Custom user management in ASP.NET Core is not a framework you install - it is a thin, well-organized layer you build on top of Identity. Keep the user store, password hashing, roles, and lockout that Identity gives you, extend IdentityUser with your own fields, and expose UserManager and RoleManager through endpoints you control. That gets you the two things MapIdentityApi never will: custom fields at registration and a real admin surface to list, search, role-manage, lock, and disable users.
The whole thing fits in one Minimal API project, and the complete source is on GitHub. Clone it, hit F5, and walk the requests.http file to see every endpoint in action. Build this when you own your users and need control over them - and only reach for an external provider when single sign-on or social login enters the picture.
Happy Coding :)
What's your take?
Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.