How to build dynamic and multi-tenant permissions in ASP.NET Core
Series: Security by Design in .NET: From JWT to Certificate-based Authentication
In previous articles we discussed:
-
JWT
-
Refresh Tokens
-
Authorization (roles vs policies)
But a real problem arises in applications:
The JWT contains limited and static information.
What do you do when you need:
-
dynamic permissions from the DB
-
multi-tenant logic
-
roles that change without re-login
Here comes Claims Transformation.
🧠 What is Claims Transformation
Claims Transformation allows you to:
modify or add claims after authentication, on every request.
ASP.NET Core provides the interface:
IClaimsTransformation
It is executed after token validation.
⚙️ 1️⃣ Implementing IClaimsTransformation
Simple example:
public class CustomClaimsTransformation : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var identity = (ClaimsIdentity)principal.Identity;
if (!identity.HasClaim(c => c.Type == "custom"))
{
identity.AddClaim(new Claim("custom", "true"));
}
return Task.FromResult(principal);
}
}
Registration:
builder.Services.AddScoped<IClaimsTransformation, CustomClaimsTransformation>();
🧩 2️⃣ Enriching Claims from the database
The most useful scenario:
you add claims from the DB on every request.
Example:
public class DbClaimsTransformation : IClaimsTransformation
{
private readonly IUserRepository _repo;
public DbClaimsTransformation(IUserRepository repo)
{
_repo = repo;
}
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var identity = (ClaimsIdentity)principal.Identity;
var userId = identity.FindFirst("sub")?.Value;
var user = await _repo.GetUserWithPermissions(userId);
foreach (var permission in user.Permissions)
{
identity.AddClaim(new Claim("permission", permission));
}
return principal;
}
}
🏢 3️⃣ Multi-tenant logic
In real applications:
a user can belong to multiple companies.
The JWT contains:
{
"sub": "user123"
}
But at runtime you need:
-
current CompanyId
-
Role in the company
-
Company-specific permissions
Claims Transformation can do this:
identity.AddClaim(new Claim("companyId", selectedCompanyId));
identity.AddClaim(new Claim("role", "Manager"));
🔄 4️⃣ Dynamic Permissions
Role-based is not enough in complex applications.
Instead:
permission = "invoice.read"
permission = "invoice.create"
permission = "invoice.delete"
Added as claims:
identity.AddClaim(new Claim("permission", "invoice.read"));
Then you use a policy:
options.AddPolicy("CanReadInvoice",
policy => policy.RequireClaim("permission", "invoice.read"));
⚠️ Issues and pitfalls
❌ Execution on every request
Claims Transformation runs on every request.
If you query the DB:
→ performance impact
✅ Solutions
-
cache (MemoryCache / Redis)
-
claim versioning
-
data limitation
❌ Duplicate claims
If you don't check:
→ you will add the same claims multiple times
❌ Too complex logic
Don't turn ClaimsTransformation into a "mini service layer".
🧱 Recommended architecture
JWT contains:
sub (userId)
email
Claims Transformation adds:
permissions
companyId
roles
Authorization uses:
policies + handlers
🔥 Best Practices
✔ Minimal JWT (only identification)
✔ Claims enrichment from DB
✔ Cache for performance
✔ Dynamic permissions
✔ Multi-tenant aware
✔ Policy-based authorization
🎯 Conclusion
Claims Transformation is the key for:
✔ dynamic permissions
✔ multi-tenant applications
✔ flexible authorization logic
It is the difference between:
a simple role system
and
an enterprise permission system