Many ASP.NET Core projects start the same way. Someone adds [Authorize(Roles = "Admin")] to a controller, it works, and the team moves on. Eighteen months later there are forty controllers checking combinations of roles like "Admin,Manager" or "Admin,SuperUser,RegionalLead", and nobody remembers why RegionalLead needs access to the invoice export endpoint but not the refund endpoint. This article walks through how to migrate role-based to policy-based authorization in ASP.NET Core without a big-bang rewrite, without breaking your existing tests overnight, and with a rollback path if something goes wrong.
Quick answer: audit every role check, register named policies that wrap your existing roles at first, run each new policy in "shadow" mode next to the old check, then switch enforcement one endpoint at a time once the logs show no disagreements.
This is not a full "what is policy-based authorization" tutorial. It assumes you know roughly what roles and policies are and need the migration playbook: how to audit an existing codebase, run both systems side by side, move claims from an identity provider, and refactor tests without rewriting your whole suite in one sprint. As a quick refresher, a role is a label such as "Admin", a policy is a named rule that ASP.NET Core evaluates, and a claim is a key-value fact about the signed-in user.
Why migrate from roles to policies
Role checks are a blunt instrument. A role answers "what group is this user in," but authorization decisions are usually about "what can this specific user do, in this specific context, right now." Those are different questions, and mixing them up is what makes role lists spiral out of control.
A few concrete problems tend to show up as an app grows:
- Role strings get overloaded.
"Admin"starts meaning "can manage users," then quietly also means "can approve refunds," because nobody wanted to add a new role for one feature. - Combining conditions gets ugly.
[Authorize(Roles = "Admin,Manager")]reads as "Admin OR Manager," which surprises developers who expect AND semantics, and there's no clean attribute syntax for logic like "Manager AND account age over 90 days." - Resource-specific checks don't fit roles at all. "Can this user edit this specific document" depends on ownership or tenant, not a static role name.
- Role names become load-bearing strings scattered across dozens of files, which makes them hard to rename or consolidate.
Policy-based authorization replaces the role string with a named policy, evaluated by one or more AuthorizationHandler classes against the user's ClaimsPrincipal — the object representing the authenticated user and all their claims, which are key-value pairs issued by the identity provider. Policies can check roles, claims, custom business rules, or the specific resource being accessed, all behind a single readable name like "CanApproveRefunds". The controller or endpoint doesn't need to know what the rule checks. That indirection is the point, and it's what keeps policies maintainable at scale in a way role strings aren't.
Claims-based authorization is closely related: claims are the underlying data ("department": "Finance", "clearance_level": "3") that policies evaluate. Moving to policies usually means moving toward richer claims too, since a policy that only checks roles hasn't solved the underlying problem. It has just renamed it.
Not every rule needs the same tool. This table shows which approach usually fits which kind of rule:
| Kind of rule | Example | Usual best fit |
|---|---|---|
| Pure group membership, no other conditions | Only Admins can delete users | A role check, or a policy that wraps RequireRole so the string lives in one place |
| Needs a fact about the user beyond a role | Finance department, clearance level 3 | A policy built on claims, using RequireClaim or RequireAssertion |
| Custom business logic reused across endpoints | At least six months in the department | A policy with a requirement and a handler |
| Depends on the specific object being accessed | Edit only your own documents | Resource-based authorization with IAuthorizationService.AuthorizeAsync |
Prerequisites
Before starting, make sure the following are true of your project. As of October 2026, Microsoft's support policy lists .NET 10 (LTS) as supported through November 2028, while .NET 8 and .NET 9 both reach end of support on November 10, 2026. New work should target .NET 10, and if you're on 8 or 9, plan the upgrade alongside this migration. Every example here uses APIs available on .NET 8 and later. AddAuthorizationBuilder itself was introduced in .NET 7. Check the documentation for your exact installed version before shipping.
- You're using ASP.NET Core Identity, an external identity provider (Azure AD / Microsoft Entra ID, IdentityServer, Auth0, Okta), or custom cookie/JWT authentication that produces a
ClaimsPrincipal. - You can modify
Program.cs(orStartup.csif you're on an older hosting model) and the authentication/authorization pipeline configuration. - You have an automated test suite covering at least the authorization-sensitive endpoints. If you don't, write a baseline set of integration tests before touching anything. You need something to tell you whether the migration changed behavior.
- You can deploy behind a feature flag or configuration toggle, even a simple one. The dual-mode approach described later depends on turning new policies on gradually rather than flipping every endpoint at once.
One assumption worth stating plainly: this guide assumes a single ASP.NET Core application, or a small number of services sharing an identity model. If you're coordinating a migration across a dozen microservices with independently deployed authorization logic, the same principles apply, but the rollout sequencing is considerably more involved than what's covered here.
Assessment — Auditing Your Current Role-Based Setup
Before writing a single policy, inventory what you actually have. This step is easy to skip, and skipping it is a common reason migrations drag on.
Search your codebase for every occurrence of [Authorize(Roles = ...)], User.IsInRole(...), and any hand-rolled role-checking middleware or filters. For each one, record three things: the role string used, the endpoint or code path it protects, and the business reason the check exists. That last one is the part people skip. Often the business reason has drifted from the role name, which is a sign the role model has outlived its usefulness.
A simple spreadsheet or markdown table works fine for this:
| Endpoint | Current check | Actual business rule | Proposed policy name |
|---|---|---|---|
POST /api/refunds/{id}/approve | Roles = "Admin,Manager" | User can approve if Manager of the requesting department, or Admin | CanApproveRefund |
GET /api/reports/export | Roles = "Admin" | Only users with export clearance | CanExportReports |
DELETE /api/users/{id} | Roles = "Admin" | Admin-only, no change needed | AdminOnly (keep as a role check, or wrap it in a policy as in Step 1) |
That last row matters. Not everything needs to become a custom policy. If a check really is "only Admins, full stop, no other conditions ever," leaving it as a role check is fine. Migrating things that don't need it just adds indirection. The goal is to replace role checks that are doing more work than a role name can honestly express, not to replace every role check on principle.
While you're auditing, check what claims your identity provider issues today. With Azure AD / Microsoft Entra ID, user app roles arrive in a claim named roles (plural), so IsInRole only works if the role claim type is set to match. Microsoft.Identity.Web usually handles that for you, and with plain JWT bearer setup you can set TokenValidationParameters.RoleClaimType = "roles". With ASP.NET Core Identity and UserManager / RoleManager, roles come from the AspNetUserRoles table and are added to the ClaimsPrincipal during sign-in. Knowing where your role claims originate matters because your new policies will often need additional claims that aren't issued yet: department, tenant ID, clearance level, whatever your audit table lists as the "actual business rule."
Step-by-step: migrate from role-based to policy-based authorization
Step 1: Register the Authorization Services and Keep Roles Working
The first step is deliberately conservative: wire up the policy infrastructure without removing anything. In Program.cs, use AddAuthorizationBuilder to register policies alongside your existing role-based attributes, which continue to work unchanged. The complete example near the end of this article moves these policy names into constants, so you don't repeat strings.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(/* your existing scheme configuration */);
builder.Services.AddAuthorizationBuilder()
.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Admin"))
.AddPolicy("CanApproveRefund", policy =>
policy.RequireAssertion(context =>
context.User.IsInRole("Admin") ||
(context.User.IsInRole("Manager") &&
context.User.HasClaim(c => c.Type == "department"))));
builder.Services.AddControllers();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
The expected result is that the app builds and behaves exactly as before, because nothing uses the new policies yet.
Notice the "AdminOnly" policy still checks a role internally. That's intentional: a policy can simply wrap a role check, so it doesn't have to introduce new logic on day one. You can rewrite [Authorize(Roles = "Admin")] as [Authorize(Policy = "AdminOnly")] everywhere, which centralizes the string "Admin" in one place without changing behavior. Future changes to what "Admin" means then touch one line instead of forty attributes.
The "CanApproveRefund" policy here is a simplified first version. It only checks that a department claim exists. Comparing the manager's department to the department that requested the refund depends on the specific refund, so it needs the resource-based approach in Step 5.
One small helper will save you trouble in later steps. Identity providers and JWT settings name the user ID claim differently. By default, ASP.NET Core's JWT bearer handler renames sub to ClaimTypes.NameIdentifier, so a hard-coded FindFirst("sub") returns null unless you turn that mapping off. ASP.NET Core Identity cookies use NameIdentifier too. This extension checks both:
using System.Security.Claims;
public static class UserClaimsExtensions
{
public static string? GetUserIdOrNull(this ClaimsPrincipal user) =>
user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst("sub")?.Value;
}
Step 2: Build Requirement and Handler Classes for Genuine Business Rules
For checks that are more than a role wrapper, build an IAuthorizationRequirement — a marker interface describing what must be true — and an AuthorizationHandler<T> that evaluates it against the current user and, optionally, a specific resource. The example below is a separate policy, "TenuredStaff": users must have been in their department for a minimum number of months, and Admins are always allowed. You'll need using directives for Microsoft.AspNetCore.Authorization and System.Globalization.
public class MinimumDepartmentTenureRequirement : IAuthorizationRequirement
{
public int MinimumMonths { get; }
public MinimumDepartmentTenureRequirement(int minimumMonths)
{
MinimumMonths = minimumMonths;
}
}
public class MinimumDepartmentTenureHandler
: AuthorizationHandler<MinimumDepartmentTenureRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumDepartmentTenureRequirement requirement)
{
var joinedClaim = context.User.FindFirst("department_joined_utc");
if (joinedClaim is not null &&
DateTimeOffset.TryParse(
joinedClaim.Value,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal,
out var joinedUtc))
{
// Approximation: a "month" is 30 days.
var daysInDepartment = (DateTimeOffset.UtcNow - joinedUtc).TotalDays;
if (daysInDepartment >= requirement.MinimumMonths * 30)
{
context.Succeed(requirement);
}
}
// Deliberately no explicit Fail() call here — see explanation below.
return Task.CompletedTask;
}
}
Parsing with DateTimeOffset.TryParse and InvariantCulture matters here. A plain DateTime.TryParse on a UTC timestamp ending in Z converts it to local time by default, which would quietly skew the tenure calculation on any server not running in UTC.
Two things are easy to get wrong in handlers like this. First, handlers run on every policy evaluation that references their requirement, and they can run for anonymous users too, so check for missing claims and keep them fast and free of side effects. Prefer reading claims already on the ClaimsPrincipal over querying a database on every authorization check. Second, avoid context.Fail() unless you mean it. A call to Fail() guarantees the requirement fails even if another handler for the same requirement calls Succeed(). In most cases, simply not calling Succeed() is the right way to let a handler decline without vetoing the others.
That second point is exactly what makes an "Admin bypass" easy. When several handlers are registered for the same requirement, the requirement passes if any of them succeeds, which gives you OR behavior. Add a second handler that only approves Admins:
public class AdminBypassTenureHandler
: AuthorizationHandler<MinimumDepartmentTenureRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumDepartmentTenureRequirement requirement)
{
if (context.User.IsInRole("Admin"))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Now register both handlers and the policy. Keep in mind that requirements added to one policy — through RequireAssertion, RequireRole, or AddRequirements — are combined with AND, so every one must succeed. That's why the Admin bypass lives in a second handler for the same requirement, not in a second requirement on the policy. If you added an admin check as its own requirement, an Admin with too little tenure would still be denied.
builder.Services.AddSingleton<IAuthorizationHandler, MinimumDepartmentTenureHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, AdminBypassTenureHandler>();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("TenuredStaff", policy =>
policy.AddRequirements(new MinimumDepartmentTenureRequirement(6)));
Apply it with [Authorize(Policy = "TenuredStaff")]. The expected result: a user with a recent department_joined_utc gets 403 Forbidden, a user whose claim is at least six months old passes, and an Admin passes regardless of the claim.
Handlers can be registered as singletons when they have no scoped dependencies, which is the common case for pure claim-checking logic. If a handler needs a scoped service like a DbContext, register it as scoped instead. Scoped services are available during authorization, but you're adding a database round trip to every protected request, so weigh that against caching the data, as Step 4 does.
Step 3: Run Both Systems in Parallel (Dual-Mode Coexistence)
This is the step that makes a production migration safe. Rather than rewriting every [Authorize(Roles = ...)] attribute in one pull request, introduce policies incrementally, endpoint by endpoint, while both styles coexist in the same application.
The idea is to keep the role attribute as the enforced check and evaluate the new policy "in the shadow" with IAuthorizationService, logging any disagreement. The diagram below shows what happens on each request.
In words: the role check runs first and can still return 403. Only requests that pass it reach the shadow policy check, and a policy denial is logged but never blocks the request. Here is the code for that flow:
public class RefundApprovalController : ControllerBase
{
private readonly ILogger<RefundApprovalController> _logger;
private readonly IAuthorizationService _authorizationService;
public RefundApprovalController(
ILogger<RefundApprovalController> logger,
IAuthorizationService authorizationService)
{
_logger = logger;
_authorizationService = authorizationService;
}
[Authorize(Roles = "Admin,Manager")] // still the enforced check
[HttpPost("{id}/approve")]
public async Task<IActionResult> Approve(int id)
{
// Shadow-evaluate the new policy without enforcing it yet.
var shadowResult = await _authorizationService.AuthorizeAsync(
User, "CanApproveRefund");
if (!shadowResult.Succeeded)
{
_logger.LogWarning(
"Policy CanApproveRefund would have DENIED a request " +
"that the role check ALLOWED for user {UserId}",
User.GetUserIdOrNull());
}
// existing approval logic continues here
return Ok();
}
}
Run this for a week or two across production traffic and you'll see every case where the new policy would have denied someone the old check allowed. When the logs are quiet for a reasonable observation period, swap the attribute to [Authorize(Policy = "CanApproveRefund")] and remove the shadow code. Do this one endpoint or controller at a time, and deploy each change independently, so a problem traces back to a small change instead of a thousand-line diff.
There's one blind spot. Because the role check blocks first, shadow mode can't show you the opposite case, where the new policy would allow someone the role check denies. Use your audit table and tests to cover that direction.
If you want an explicit kill switch instead of relying on redeploys, choose the check at request time from configuration. The decision has to happen inside the action, because attribute arguments must be compile-time constants. This version assumes you've also injected IConfiguration as _configuration and keep a plain [Authorize] on the controller so only authenticated users get this far:
var useNewPolicy = _configuration.GetValue<bool>("Authorization:UseNewRefundPolicy");
var allowed = useNewPolicy
? (await _authorizationService.AuthorizeAsync(User, "CanApproveRefund")).Succeeded
: User.IsInRole("Admin") || User.IsInRole("Manager");
if (!allowed)
{
return Forbid();
}
Because the setting is read on each request, flipping it takes effect without a deployment. JSON configuration files reload on change by default in a standard web app, but other providers, such as environment variables, may need a restart, so confirm how your own configuration reloads. It's more code, but a real feature flag with fast rollback is worth it for your highest-risk endpoints.
Step 4: Migrate Claims from Your Identity Provider
If your policies need claims your identity provider doesn't issue yet — department, clearance level, tenant ID — you have two options: update the provider's token configuration, or add a claims transformation step inside your ASP.NET Core app.
Claims transformation lets you enrich the ClaimsPrincipal with claims from your own data store. That helps when you can't change what an external provider like Azure AD or Auth0 puts in the token. One thing to know before you write it: IClaimsTransformation runs each time the user is authenticated, which for most APIs means every request. A database lookup inside it is therefore a per-request cost, so the example below caches the profile briefly. It also returns a copy of the principal instead of modifying the original, which is what Microsoft's documentation recommends when a transformation isn't idempotent. Here, IUserProfileService is a stand-in for your own profile lookup:
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Caching.Memory;
public class DepartmentClaimsTransformation : IClaimsTransformation
{
private readonly IUserProfileService _profileService;
private readonly IMemoryCache _cache;
public DepartmentClaimsTransformation(
IUserProfileService profileService,
IMemoryCache cache)
{
_profileService = profileService;
_cache = cache;
}
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.Identity is not ClaimsIdentity { IsAuthenticated: true })
{
return principal;
}
// Skip if the token already carries the claim, or it was added earlier in this request.
if (principal.HasClaim(c => c.Type == "department"))
{
return principal;
}
var userId = principal.GetUserIdOrNull();
if (userId is null)
{
return principal;
}
var profile = await _cache.GetOrCreateAsync(
$"user-profile:{userId}",
entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return _profileService.GetProfileAsync(userId);
});
if (profile is null)
{
return principal;
}
// Work on a copy so the original principal is never modified.
var clone = principal.Clone();
var cloneIdentity = (ClaimsIdentity)clone.Identity!;
cloneIdentity.AddClaim(new Claim("department", profile.Department));
cloneIdentity.AddClaim(new Claim(
"department_joined_utc",
profile.DepartmentJoinedUtc.ToString("O")));
return clone;
}
}
Register the pieces with builder.Services.AddMemoryCache(); and builder.Services.AddTransient<IClaimsTransformation, DepartmentClaimsTransformation>();. The expected result is that User.Claims now includes department on authenticated requests. The trade-off is that department changes can take up to five minutes to show up, so pick a cache window your business can live with.
If you control the external provider's configuration, it's often cleaner to issue the claims directly in the token (an Azure AD app role, or a custom claim through a claims mapping policy) than to enrich on every request, because that avoids the lookup. The right choice depends on how much control you have and how often the data changes. Department tenure changes slowly, so caching it or even embedding it in the token is reasonable. Something like "current account balance" should almost never end up in a long-lived claim.
One migration trap deserves its own warning. Tokens and cookies issued before you added a claim don't contain it. Until those users sign in again or their token refreshes, a policy that requires the new claim will deny them. Shadow mode (Step 3) is exactly how you catch this before it affects anyone, so don't skip the observation period after changing claim sources.
Step 5: Add Resource-Based Authorization Where Role Checks Can't Express the Rule
Some rules from your audit will look like "a user can edit a document only if they own it." No role or static policy can express that, because the answer depends on the specific resource being accessed. That's where resource-based authorization comes in: authorization decisions that take the actual object being acted on into account, not just the user.
public class DocumentOwnerRequirement : IAuthorizationRequirement { }
public class DocumentOwnerHandler
: AuthorizationHandler<DocumentOwnerRequirement, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
DocumentOwnerRequirement requirement,
Document resource)
{
var userId = context.User.GetUserIdOrNull();
if (userId is not null && resource.OwnerId == userId)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Register the handler like the earlier ones:
builder.Services.AddSingleton<IAuthorizationHandler, DocumentOwnerHandler>();
This style of handler can't be invoked through the [Authorize] attribute alone, since the attribute has no way to supply the resource instance. Call it explicitly inside the action method instead. This snippet assumes _authorizationService and _documentRepository are injected into your controller, and that Document is your own entity class:
[HttpPut("{id}")]
public async Task<IActionResult> UpdateDocument(int id, DocumentUpdateDto dto)
{
var document = await _documentRepository.GetByIdAsync(id);
if (document is null)
{
return NotFound();
}
var authResult = await _authorizationService.AuthorizeAsync(
User, document, new DocumentOwnerRequirement());
if (!authResult.Succeeded)
{
return Forbid();
}
// proceed with update
return NoContent();
}
It's common to combine resource-based checks with a simple policy check first. For example, require "CanEditDocuments" as a baseline policy through the attribute, then layer the ownership check inside the action for the specific instance. One design note: returning 404 for a missing document but 403 for someone else's document tells a caller that the document exists. For sensitive or multi-tenant data, consider returning 404 in both cases.
Bonus: Use the Same Policies on Minimal API Endpoints
If part of your app uses minimal APIs instead of controllers, the same named policies apply. Instead of the attribute, call RequireAuthorization with the policy name:
app.MapPost("/api/refunds/{id}/approve", (int id) => Results.Ok())
.RequireAuthorization("CanApproveRefund");
This is why centralizing rules in policies pays off: the rule is defined once, and both controllers and minimal APIs reference it by name.
Complete End-to-End Example
The earlier steps show one piece at a time. This section puts the final state together so you can see how the parts connect. Treat it as a starting point and adapt the names to your project. It was reviewed by inspection and not compiled, so build it and run your tests before relying on it.
First, move policy names into constants so no string is repeated. Constants work inside attributes because they're compile-time values.
public static class AuthPolicies
{
public const string AdminOnly = "AdminOnly";
public const string CanApproveRefund = "CanApproveRefund";
public const string TenuredStaff = "TenuredStaff";
}
Next comes Program.cs, with JWT bearer authentication, claims enrichment, handlers, and policies in one place. The authority, audience, and role claim type come from configuration. MapInboundClaims = false keeps claim names as the provider issued them, and GetUserIdOrNull() works either way.
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = builder.Configuration["Auth:Audience"];
// Keep short claim names such as "sub" exactly as issued.
options.MapInboundClaims = false;
// Entra ID emits roles in "roles"; use "role" for providers that do.
options.TokenValidationParameters.RoleClaimType =
builder.Configuration["Auth:RoleClaimType"] ?? "roles";
});
builder.Services.AddMemoryCache();
builder.Services.AddScoped<IUserProfileService, UserProfileService>(); // your implementation
builder.Services.AddTransient<IClaimsTransformation, DepartmentClaimsTransformation>();
builder.Services.AddSingleton<IAuthorizationHandler, MinimumDepartmentTenureHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, AdminBypassTenureHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, DocumentOwnerHandler>();
builder.Services.AddAuthorizationBuilder()
.AddPolicy(AuthPolicies.AdminOnly, policy =>
policy.RequireRole("Admin"))
.AddPolicy(AuthPolicies.CanApproveRefund, policy =>
policy.RequireAssertion(context =>
context.User.IsInRole("Admin") ||
(context.User.IsInRole("Manager") &&
context.User.HasClaim(c => c.Type == "department"))))
.AddPolicy(AuthPolicies.TenuredStaff, policy =>
policy.AddRequirements(new MinimumDepartmentTenureRequirement(6)));
builder.Services.AddControllers();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
// Lets WebApplicationFactory<Program> see the entry point in tests.
public partial class Program { }
After the shadow period, the refund controller ends up with a plain policy attribute and no migration scaffolding left:
[ApiController]
[Route("api/refunds")]
public class RefundsController : ControllerBase
{
[Authorize(Policy = AuthPolicies.CanApproveRefund)]
[HttpPost("{id:int}/approve")]
public IActionResult Approve(int id)
{
// approval logic goes here
return Ok();
}
}
Common problems and how to fix them
Mismatched expectations between RequireRole and combined requirements trip up a lot of migrations. RequireRole("Admin", "Manager") means "has Admin OR Manager," matching the original attribute's OR semantics exactly. Stacking separate .RequireClaim(...) calls, or adding separate requirements to one policy, is evaluated as AND. Stacking several [Authorize] attributes on one endpoint is also AND. Mixing these up silently changes who gets access, so check which combinator you need against your audit table before enforcing a policy.
A requirement with no handler that succeeds always fails, because the default outcome of an unhandled requirement is denial. That includes the case where you forgot to register the handler at all. It's also easy to hit when a conditional branch inside a handler doesn't cover every path. Make sure every branch either calls Succeed() or deliberately falls through to the implicit failure, and add a unit test for each branch.
Shadow-evaluation logging, if left in production longer than intended, adds noise and a small amount of overhead to every request on that endpoint. Treat it as temporary scaffolding with a removal date tracked in your issue tracker.
Claims transformation runs on each authentication, not once per login. A lookup inside it therefore costs you on every request unless you cache it, as Step 4 does. Keep the guard against duplicate claims, and write an integration test that asserts a claim appears exactly once on the resulting principal.
Policies that suddenly deny existing users after you add a new claim usually mean those users hold tokens or cookies issued before the claim existed. They'll pass once they sign in again or their token refreshes. Shadow mode surfaces this before enforcement, which is another reason not to skip it.
Finally, claim names can differ between providers, and between versions of the same provider's SDK. By default, the JWT bearer handler maps short names such as sub to long schema-URL claim types. You can turn that off with MapInboundClaims = false, and the GetUserIdOrNull() helper from Step 1 works either way. Microsoft Entra ID also emits app roles in a claim named roles (plural), so IsInRole only works if the role claim type is configured to match. If role or claim checks silently fail after a provider change, inspect the actual claims on User.Claims in a debugger or temporary logging before assuming your policy logic is wrong. It's often a claim type mismatch, not a logic bug.
Verification — Confirming a Successful Migration
Before calling the migration complete for an endpoint, verify each of the following.
Run your full integration test suite during the dual-mode period. Integration tests using WebApplicationFactory<TProgram> work well here, since they exercise the real authorization pipeline instead of mocking it. The tests below use xUnit. They rely on a CreateClientWithUser helper, which the next code block defines. If your claims transformation calls a real service, register a fake for it in your test setup.
[Fact]
public async Task Manager_In_A_Department_Can_Approve_Refund()
{
var client = _factory.CreateClientWithUser(
roles: new[] { "Manager" },
claims: new[] { new Claim("department", "Finance") });
var response = await client.PostAsync(
"/api/refunds/42/approve", content: null);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Manager_Without_A_Department_Is_Forbidden()
{
var client = _factory.CreateClientWithUser(
roles: new[] { "Manager" },
claims: Array.Empty<Claim>());
var response = await client.PostAsync(
"/api/refunds/42/approve", content: null);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
Here is one way to build the helper. A small test authentication handler turns request headers into a real ClaimsPrincipal, so the real authorization pipeline evaluates it:
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "Test";
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("X-Test-User", out var userId))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var claims = new List<Claim> { new("sub", userId.ToString()) };
foreach (var role in Request.Headers["X-Test-Roles"].ToString()
.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
if (Request.Headers.TryGetValue("X-Test-Department", out var department))
{
claims.Add(new Claim("department", department.ToString()));
}
var identity = new ClaimsIdentity(claims, SchemeName);
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
public static class AuthTestExtensions
{
public static HttpClient CreateClientWithUser(
this WebApplicationFactory<Program> factory,
string[] roles,
Claim[] claims)
{
var client = factory
.WithWebHostBuilder(builder =>
builder.ConfigureTestServices(services =>
services
.AddAuthentication(TestAuthHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.SchemeName, _ => { })))
.CreateClient();
client.DefaultRequestHeaders.Add("X-Test-User", "test-user-1");
client.DefaultRequestHeaders.Add("X-Test-Roles", string.Join(',', roles));
var department = claims.FirstOrDefault(c => c.Type == "department");
if (department is not null)
{
client.DefaultRequestHeaders.Add("X-Test-Department", department.Value);
}
return client;
}
}
This replaces the older style of test that mocked User.IsInRole("Manager") directly. Because a real principal flows through the real pipeline, these tests catch configuration mistakes, like a policy registered under the wrong name, that a mocked IsInRole call never would. The second test covers the deny direction, which shadow mode can't show you.
Confirm that denied requests return the status code your API contract expects. By default, an authenticated user who fails a role or policy check gets 403 Forbidden, and an unauthenticated user gets 401 Unauthorized. With cookie authentication, the default behavior is a redirect to the login or access-denied page instead. Check that nothing changed for any endpoint during the migration, since a mismatch is a subtle breaking change for API consumers.
Check your logs from the shadow period for zero mismatches over a representative traffic window before removing any role-based attribute. If mismatches exist, investigate each one individually. Sometimes the new policy is correct and the old role check was the bug, which is worth documenting when it happens.
Finally, once every endpoint in your audit table has been migrated or deliberately left alone, search the codebase one last time for leftover [Authorize(Roles = ...)] and User.IsInRole(...) usages. Confirm each remaining one is an intentional, documented exception rather than something left behind by accident.
Frequently asked questions
Can I use roles and policies in the same application?
Yes. A policy can wrap a role check with RequireRole, which is exactly how Step 1 starts. Roles and policies can coexist for as long as the migration takes, and some role checks may stay forever.
How do multiple [Authorize] attributes combine?
All of them must succeed, so stacked attributes behave as AND. Inside a single Roles = "A,B" value, the roles behave as OR.
Do I have to use AddAuthorizationBuilder?
No. It's a shorter way to register policies, available since .NET 7. The older AddAuthorization(options => options.AddPolicy(...)) form registers the same policies and works on every version.
Why did my new policy deny users who should have access?
The most common causes are a claim the user's existing token doesn't contain yet, a claim type mismatch such as roles versus role, or AND/OR confusion between requirements and handlers. The "Common problems" section above walks through each.
Summary and next step
To migrate role-based to policy-based authorization in ASP.NET Core, you don't remove roles. You move the business rules that role strings were awkwardly encoding into named policies and requirement handlers. Audit first, wrap existing roles in policies, shadow-test each new policy against real traffic, bring in the claims it needs, and switch enforcement one endpoint at a time. Because the new policy can't deny anyone until you flip it, you catch disagreements before users do.
Pick the messiest role check in your own audit table, the one combining three roles with a comment nobody trusts anymore, and take it through Steps 1 to 3 this week instead of attempting the whole application at once.
