Microsoft Entra External ID for customers, usually shortened to CIAM, gives you a proper identity platform for customer facing applications. Until this offering arrived, most teams building customer apps on Azure had to fall back to Azure AD B2C, and B2C never gave you a clean way to do role based authorization with groups. This article walks through implementing group and role based authorization in an ASP.NET Core application secured with Entra External ID CIAM, using the same App roles and Enterprise application group assignment model that Azure AD has offered for years.
The setup here builds on an earlier article covering authentication with Entra External ID CIAM. Once authentication is in place, the next real problem is authorization: deciding which authenticated user gets to see which page or call which API. That is what this piece focuses on.
Why B2C developers will appreciate this
In regular Azure AD, App roles combined with security groups have always been the standard way to control access. You define roles on an app registration, assign those roles to users or groups through the Enterprise application blade, and your application reads the resulting role claims to make authorization decisions. It is a well understood pattern and most Azure AD developers have used it in some form.
Azure AD B2C never supported this cleanly. Teams building B2C apps had to invent their own authorization schemes, often storing role or permission data in custom attributes or an external database, then stitching that into custom policies. Entra External ID CIAM removes that pain because it uses the same App roles and group assignment infrastructure as regular Entra ID (Azure AD). If you have ever set up role based access in a normal Azure AD tenant, this will feel familiar.
Creating app roles in the app registration
App roles are defined on the app registration in the App roles blade. Each role needs a display name, a value that shows up in the roles claim, and a member type. This last part matters: the member type must be set to Users/Groups, not Applications, because these roles are meant for delegated, human authenticated identities rather than for app to app client credential flows.

A common mistake here is leaving the member type on the default or picking Applications by accident, which quietly breaks the whole flow. If your roles do not show up in the token later, this is the first place to check. It is a small setting but it decides whether the role is even eligible for delegated assignment.
Assigning roles to users and groups
Once the roles exist, you assign them to identities through the Enterprise application for that same app registration, under Users and groups. You can assign a role directly to an individual user, but in any environment beyond a small pilot you would assign roles to security groups instead, and manage group membership separately. This keeps role assignment decoupled from individual user administration, which is exactly how larger Azure tenants already operate.

In practice, dynamic security groups work well here if you already drive group membership from an attribute in your directory. Standard assigned groups work fine too, and are usually simpler to reason about when the user base is customer facing and does not follow predictable attribute patterns. Either way, the important part is that the group, not the individual user, becomes the unit of role assignment.
Reading role claims in the ASP.NET Core application
Getting the role claim into the application takes almost no extra work if you are already using the Microsoft.Identity.Web client library. Authentication and claims mapping are handled by the library, though you occasionally need to correct claim type namespaces, since Microsoft.Identity.Web sometimes maps roles under a different claim type than you expect depending on the token version and configuration. Once that is sorted, the roles claim is available on the ClaimsPrincipal like any other claim.
The following Razor Page handler pulls every role claim off the authenticated user and adds it to a list for display.
public void OnGet()
{
var claims = User.Claims.ToList();
foreach(var claim in claims)
{
if(claim != null && claim.Type == "roles")
{
Roles.Add(claim.Value);
}
}
}
This just loops through User.Claims and collects any claim of type roles into a Roles list on the page model. It is a debugging or diagnostics view more than production code, useful for confirming that the claim actually arrived with the value you expect before you wire up real authorization against it. The corresponding Razor markup below renders that list on the page.
@{
foreach( var role in Model.Roles)
{
<p><b>Role:</b> @role</p>
}
}
Nothing complicated there, it just iterates through Model.Roles and prints each one. The output is a page listing every role the current user carries, which is a handy sanity check when you are setting up a new environment and want to confirm role assignment actually took effect before building policies on top of it.
Using policies instead of checking raw claims
It is tempting to check claim.Type == “roles” directly inside a controller or page, but you should avoid that. Always define an ASP.NET Core authorization policy and map the claim to a requirement in one place, then reference the policy everywhere else. If the role name or claim type ever changes, you fix it in a single spot instead of hunting across the codebase.
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("UserPolicy", policy => {
policy.RequireClaim("roles", "user-role");
});
options.AddPolicy("AdminPolicy", policy => {
policy.RequireClaim("roles", "admin-role");
});
options.AddPolicy("UserAdminPolicy", policy => {
policy.AddRequirements(new UserAdminRequirement());
});
options.FallbackPolicy = options.DefaultPolicy;
});
This registers three policies. UserPolicy and AdminPolicy each use RequireClaim to check for a specific role value directly, which is fine for simple single condition checks. UserAdminPolicy is different, it delegates to a custom requirement class instead, which is what you reach for once the logic gets more involved than a single claim comparison. The FallbackPolicy line is worth calling out too, setting it to DefaultPolicy means any endpoint without an explicit [Authorize] or [AllowAnonymous] attribute still requires an authenticated user by default, which is a sensible safety net against someone forgetting to secure a new page.
Custom authorization handlers for anything more complex
Once you need logic beyond a single RequireClaim check, such as accepting either of two roles, or combining a role with some other condition, an AuthorizationHandler is the right tool. It starts with an empty requirement class that just marks the policy as needing custom evaluation.
public class UserAdminRequirement : IAuthorizationRequirement {}
That class carries no data or logic itself, it exists purely as a marker type that ASP.NET Core’s authorization system uses to route evaluation to the matching handler. The actual decision logic lives in the handler shown next.
public class UserAdminHandler
: AuthorizationHandler<UserAdminRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
UserAdminRequirement requirement)
{
var userRole = context.User
.FindFirst(c => c.Type == "roles"
&& c.Value == "user-role");
var adminRole = context.User
.FindFirst(c => c.Type == "roles"
&& c.Value == "admin-role");
if (userRole != null || adminRole != null)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
HandleRequirementAsync checks whether the current principal has either the user-role or the admin-role claim, and calls context.Succeed if either is present. This is essentially an OR condition across two roles, something RequireClaim alone cannot express cleanly since RequireClaim with multiple values actually checks for any of those values on a single claim type, which happens to work here but stops being readable once you mix different claim types or add extra conditions like tenant checks or feature flags. One thing to watch for: if you never call context.Succeed, the requirement silently fails, so it is easy to write a handler that looks correct but never authorizes anyone if your condition logic has a bug.
The handler needs to be registered in the dependency injection container before it will run.
builder.Services.AddSingleton<IAuthorizationHandler, UserAdminHandler>();
This registers UserAdminHandler as a singleton implementation of IAuthorizationHandler. If you forget this line, the policy that references UserAdminRequirement will never succeed, because ASP.NET Core has no handler to evaluate the requirement against, and the request just gets rejected with no obvious error pointing at the missing registration. Finally, the policy gets applied to a page or controller using the standard Authorize attribute.
[Authorize(Policy = "UserAdminPolicy")]
public class UserAdminModel : PageModel
Any request to this Razor Page now runs through the UserAdminPolicy, which in turn runs the UserAdminHandler logic. If neither the user-role nor admin-role claim is present, ASP.NET Core returns a forbidden result before the page handler even executes.
Verifying the flow end to end
After wiring this up, the useful test is logging in as a user who is missing the required role and confirming the page actually blocks them. In the sample here, a signed in user without admin rights hits the user administration page and gets rejected, exactly as expected.

It is worth calling out that forcing authorization at the backend is necessary but not sufficient on its own. You would normally also hide the navigation link to a restricted page for users who cannot access it, purely for usability. That is a UI concern though, not a security boundary, the real enforcement has to happen server side because a hidden link is trivial to work around by typing the URL directly.

Once a user carrying the correct role signs in, the same page renders normally. Between the two screenshots you get a clear before and after that the policy and handler are actually doing their job, not just compiling without errors.
Production considerations
A few things worth keeping in mind before you take this pattern into a real customer facing application. Role and group assignment through the Azure portal does not scale well once you have more than a handful of roles or a rotating set of groups, so plan for automating assignment through Microsoft Graph or an IaC pipeline fairly early rather than treating portal clicks as the long term process.
Token size is another practical limit. If a user belongs to a very large number of groups, Entra ID can emit an overage claim instead of the full groups claim, and your application then has to call Microsoft Graph to resolve group membership separately. App roles assigned through App role assignments generally avoid this specific problem since roles are emitted directly rather than as raw group GUIDs, which is one more reason to prefer roles over reading the raw groups claim directly in code.
Finally, treat this role model as coarse grained access control, not fine grained permissions. It works well for distinguishing broad categories like user versus admin, but if your application needs per-record or per-tenant permission checks, you will still need application level authorization logic layered on top, most likely backed by your own permissions table rather than more and more App roles.
Leave a Reply