Master Claims Transformation for Flexible ASP.NET Core Authorization
Learn claims transformation in ASP.NET Core to enrich the user identity and build flexible, policy-based authorization with IClaimsTransformation.
Master Claims Transformation for Flexible ASP.NET Core Authorization
Imagine you go to a wedding hall. At the gate, a guard checks your invitation card. The card proves who you are. But the card does not say which areas you can enter. The dinner hall? The stage? The family-only room?
So a helper at the entrance looks at your name, checks a small list, and puts a coloured sticker on your card. Green sticker means you can eat. Gold sticker means you can go on stage. Now, every guard inside the hall just looks at your sticker. They do not need to ask who you are again.
In ASP.NET Core, that coloured sticker is a claim. The helper who adds the sticker is claims transformation. This article will teach you how to use it to build clean, flexible authorization.
What is a claim, really?
A claim is a small piece of information about a user. It is just a name and a value. For example:
name= "Aisha"email= "[email protected]"role= "Admin"permission= "orders.read"
When a user logs in, ASP.NET Core builds a ClaimsPrincipal. Think of it as the user's ID card with many claims printed on it. Your authorization rules then read these claims to decide: can this person do this action or not?
The problem is that the claims you get at login are often not the claims you need. A login token from Google or Microsoft Entra ID might give you an email and a user id, but nothing about roles or permissions inside your app. That gap is exactly what claims transformation fills.
The big idea: enrich the identity
Authentication answers one question: who are you? Authorization answers a different question: what are you allowed to do?
Between these two steps, there is a perfect spot to add extra information. The user is already known, but no access decision has been made yet. This is where claims transformation lives. You take the basic identity and you enrich it. You add the claims your app actually needs.
Here is a simple way to picture the journey of a user's identity.
The identity journey
Steps
Login
User signs in
Token Claims
Basic email and id
Transform
Add roles and permissions
Rich Identity
Full claim set
Decision
Allow or deny
Meet IClaimsTransformation
ASP.NET Core gives you one small interface for this job: IClaimsTransformation. It has a single method.
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
public class MyClaimsTransformation : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
// We get the user. We can add claims. We return the user.
return Task.FromResult(principal);
}
}The framework calls TransformAsync and hands you the current ClaimsPrincipal. Your job is to return a principal, usually the same one, but with extra claims added. After this runs, the authorization step sees your new claims.
To turn it on, you register it in Program.cs.
builder.Services.AddTransient<IClaimsTransformation, MyClaimsTransformation>();That is all the wiring you need. From now on, your transformation runs on each request after authentication.
A first real example: adding a role
Let us say your login token gives you an email, but your app decides who is an admin based on a list. We can add a role claim for admins.
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
public class AdminClaimsTransformation : IClaimsTransformation
{
private static readonly string[] Admins =
{
"[email protected]",
"[email protected]"
};
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var email = principal.FindFirstValue(ClaimTypes.Email);
// Only add the role if it is missing. This keeps us idempotent.
bool alreadyAdmin = principal.IsInRole("Admin");
if (email is not null && Admins.Contains(email) && !alreadyAdmin)
{
var identity = (ClaimsIdentity)principal.Identity!;
identity.AddClaim(new Claim(ClaimTypes.Role, "Admin"));
}
return Task.FromResult(principal);
}
}Notice the check !alreadyAdmin. We only add the role if it is not there yet. This is the single most important rule in claims transformation, and we will explain why next.
The golden rule: be idempotent
"Idempotent" is a big word for a simple idea. It means: running it many times gives the same result as running it once.
Why does this matter? Because TransformAsync can be called more than once during a single request. If your code blindly adds a role claim every time it runs, the user could end up with the same role two or three times. That can cause strange bugs and bloat the identity.
So the safe pattern is always: check first, then add.
Here is a tiny helper that makes the check easy to reuse.
private static void AddIfMissing(ClaimsIdentity identity, string type, string value)
{
bool exists = identity.HasClaim(c => c.Type == type && c.Value == value);
if (!exists)
{
identity.AddClaim(new Claim(type, value));
}
}With this helper, your transformation stays clean and you never create duplicates, even if the method runs several times.
A richer example: permissions from a database
Real apps rarely store permissions in a hard-coded list. They keep them in a database. So a common job for claims transformation is: read the user's permissions from the database and add them as claims.
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
public class PermissionClaimsTransformation : IClaimsTransformation
{
private readonly IPermissionService _permissions;
public PermissionClaimsTransformation(IPermissionService permissions)
{
_permissions = permissions;
}
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var identity = (ClaimsIdentity)principal.Identity!;
// If we have already added permissions, do not do it again.
if (identity.HasClaim(c => c.Type == "permission"))
{
return principal;
}
var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null)
{
return principal;
}
var userPermissions = await _permissions.GetForUserAsync(userId);
foreach (var permission in userPermissions)
{
identity.AddClaim(new Claim("permission", permission));
}
return principal;
}
}This works, but it has a hidden cost. It hits the database on every request. We will fix that with caching below. First, let us see how these claims get used.
Using the claims in authorization policies
Once the claims are on the identity, authorization is the easy part. You define a policy that requires a claim, and you attach it to your endpoints.
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanReadOrders", policy =>
policy.RequireClaim("permission", "orders.read"));
options.AddPolicy("CanDeleteOrders", policy =>
policy.RequireClaim("permission", "orders.delete"));
});Now protect an endpoint with the policy name.
app.MapGet("/orders", () => "Here are the orders")
.RequireAuthorization("CanReadOrders");
app.MapDelete("/orders/{id}", (int id) => $"Deleted order {id}")
.RequireAuthorization("CanDeleteOrders");Note how the route /orders/{id} uses curly braces inside a code block, which is safe. In normal text we would write it as DELETE /orders/{id} inside backticks so the page does not break.
The beauty here is separation. Your endpoint does not care how a user got the orders.read permission. It only checks that the claim is present. The transformation owns the "how", and the policy owns the "what is required". This keeps both parts simple.
Performance: do not punish every request
Because transformation runs on each request, a database call inside it can slow your whole app. Imagine a busy site getting one thousand requests a second. That would be one thousand extra database trips, just to load the same permissions again and again.
The fix is caching. We store the user's permissions for a short time. The first request loads from the database. The next requests, for a few minutes, read from memory.
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var identity = (ClaimsIdentity)principal.Identity!;
if (identity.HasClaim(c => c.Type == "permission"))
{
return principal;
}
var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null)
{
return principal;
}
// Cache the result for 5 minutes so we do not hit the DB each time.
var permissions = await _cache.GetOrCreateAsync(
$"perm-{userId}",
async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return await _permissions.GetForUserAsync(userId);
});
foreach (var permission in permissions!)
{
identity.AddClaim(new Claim("permission", permission));
}
return principal;
}There is a trade-off with caching. If you remove a user's permission, they might keep it until the cache expires. A short cache time, like five minutes, is a good balance for most apps. For very sensitive actions, you may want a shorter time or no cache at all.
Common approaches compared
There is more than one way to enrich an identity. Here is how the main options compare.
| Approach | When it runs | Good for | Watch out for |
|---|---|---|---|
| Claims in the token | At login only | Stable data like user id | Token gets large; stale until re-login |
IClaimsTransformation | Each request | App-specific roles and permissions | Runs often; cache slow lookups |
| Lookup inside the endpoint | When called | One-off special cases | Logic spread across many places |
And here is a quick guide to the small pieces you will touch most often.
| Member | What it does | Example |
|---|---|---|
principal.Identity | The user's identity object | Cast to ClaimsIdentity to add claims |
FindFirstValue(type) | Read one claim value | FindFirstValue(ClaimTypes.Email) |
HasClaim(predicate) | Check if a claim exists | Used for the idempotent guard |
identity.AddClaim(claim) | Add a new claim | new Claim("permission", "orders.read") |
IsInRole(name) | Check a role claim | principal.IsInRole("Admin") |
Mapping external groups to internal roles
A very common real task: your company uses Microsoft Entra ID. Users belong to groups there, like HR-Team or Finance-Team. But your app talks in roles like HrManager. Claims transformation is the perfect bridge to map one to the other.
Group to role mapping
Steps
Entra Group
HR-Team
Mapping Table
Lookup rule
App Role
HrManager
Policy
RequireRole
The mapping itself is just a small dictionary, and again we check before we add.
private static readonly Dictionary<string, string> GroupToRole = new()
{
["HR-Team"] = "HrManager",
["Finance-Team"] = "Accountant"
};
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var identity = (ClaimsIdentity)principal.Identity!;
foreach (var groupClaim in principal.FindAll("groups"))
{
if (GroupToRole.TryGetValue(groupClaim.Value, out var role)
&& !principal.IsInRole(role))
{
identity.AddClaim(new Claim(ClaimTypes.Role, role));
}
}
return Task.FromResult(principal);
}Now your policies can simply say RequireRole("HrManager"). If the company renames a group tomorrow, you change one line in the mapping, not a hundred endpoints.
A few gentle warnings
Claims transformation is powerful, but a few habits keep you safe.
- Do not throw on missing data. If a claim you expect is not there, return the principal as-is. Crashing the request is worse than missing one claim.
- Do not store secrets in claims. Claims can travel with the request. Keep passwords and keys out of them.
- Keep it small. Add only the claims your authorization rules truly need. A huge identity is slow to carry around.
- Always guard against duplicates. Repeat after me: check first, then add.
Putting it all together
Here is the full mental model. Authentication says who you are. Claims transformation runs next and gives you the right stickers. Authorization reads those stickers and lets you in or keeps you out. Each part has one clear job, and that is what makes the design flexible.
When you need a new permission tomorrow, you usually touch only two places: the transformation that adds the claim, and the policy that requires it. Your endpoints stay clean and unchanged.
Quick recap
- A claim is a small name-value fact about a user, like a coloured sticker on an ID card.
- Claims transformation runs after authentication and before authorization, the perfect spot to enrich the identity.
- Implement
IClaimsTransformationand its singleTransformAsyncmethod, then register it withAddTransient. - The golden rule is to be idempotent: always check if a claim exists before adding it, because the method can run more than once.
- Use the new claims in authorization policies with
RequireClaimorRequireRole. - Transformation runs on every request, so cache slow lookups like database calls.
- It is great for mapping external groups to internal roles and keeping your endpoints simple.
References and further reading
- Map, customize, and transform claims in ASP.NET Core (Microsoft Learn)
- IClaimsTransformation Interface (Microsoft Learn)
- Master Claims Transformation for Flexible ASP.NET Core Authorization (Milan Jovanovic)
- How to use Claims Transformation in ASP.NET Core (ReferBruv)
- Fixing Claims the Right Way (Julio Casal)
Related Posts
Authentication and Authorization Best Practices in ASP.NET Core
A friendly guide to authentication and authorization in ASP.NET Core for .NET 10 — JWT, cookies, claims, roles, policies, and security best practices with diagrams.
Building Secure APIs with Role-Based Access Control in ASP.NET Core
Learn role-based access control (RBAC) in ASP.NET Core. Add roles to JWT tokens, guard endpoints with policies, and return correct 401 and 403 codes, with diagrams and code.
Building Async APIs in ASP.NET Core the Right Way
Learn to build fast, safe async APIs in ASP.NET Core: async/await, CancellationToken, avoiding .Result deadlocks, and thread pool tips.
Using Scoped Services From Singletons in ASP.NET Core
Learn the safe way to use scoped services inside a singleton in ASP.NET Core using IServiceScopeFactory, with simple examples and clear diagrams.
How to Add JWT Authentication to SignalR Hubs in ASP.NET Core
A beginner-friendly guide to securing SignalR hubs with JWT tokens in ASP.NET Core, including the access_token query string trick and the [Authorize] attribute.
How to Scale Long-Running API Requests in .NET: A Beginner's Guide
Learn how to handle slow, long-running API requests in .NET using the 202 Accepted pattern, background services, channels, and status polling.