Introduction
Modern microservice architectures rarely deal with simple “logged-in vs not logged-in” authorization. Real-world systems must make authorization decisions based on context — who the user is, which customer they belong to, and which company or tenant they are operating under.
In this article, we will design an enterprise-grade, attribute-based authorization system in ASP.NET Core Web API using JWT claims such as:
UserId
CustomerId
CompanyId
UserType
We will:
Validate tokens locally (no runtime auth-service calls)
Enforce authorization using policies
Expose intent using custom attributes
Consume contextual data safely inside controllers
Keep controllers clean and scalable
This approach is suitable for multi-tenant systems, B2B SaaS platforms, and microservice ecosystems.
Why Attribute-Based Authorization?
Traditional role-based authorization ([Authorize(Roles = "Admin")]) quickly breaks down when:
Users operate in multiple companies
Access depends on ownership, not just role
APIs require different context levels per endpoint
Attributes allow intent to be declared at the endpoint, while policies centralize the rules.
JWT Token Design (Context-Aware)
A context-aware JWT might look like this:
{
"UserType": "Transporter",
"UserId": "89",
"CustomerId": "501",
"CompanyId": "42",
"exp": 1770228296
}
Each claim represents authorization context, not UI state.
Authentication Configuration
JWT validation happens locally in each microservice.
builder.Services
.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey =
new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("VeryStrongSecretKeyHere"))
};
});
This ensures:
Defining Authorization Policies
Policies describe rules, not endpoints.
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Transporter", policy =>
policy.RequireClaim("UserType", "Transporter"));
options.AddPolicy("TransporterWithCustomer", policy =>
policy.RequireAssertion(ctx =>
ctx.User.HasClaim("UserType", "Transporter") &&
ctx.User.HasClaim(c => c.Type == "CustomerId")));
options.AddPolicy("TransporterWithCompany", policy =>
policy.RequireAssertion(ctx =>
ctx.User.HasClaim("UserType", "Transporter") &&
ctx.User.HasClaim(c => c.Type == "CompanyId")));
options.AddPolicy("TransporterWithCustomerAndCompany", policy =>
policy.RequireAssertion(ctx =>
ctx.User.HasClaim("UserType", "Transporter") &&
ctx.User.HasClaim(c => c.Type == "CustomerId") &&
ctx.User.HasClaim(c => c.Type == "CompanyId")));
});
Each policy expresses minimum required context.
Creating Custom Authorization Attributes
Attributes expose policies in a clean, reusable way.
Transporter Only
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class TransporterOnlyAttribute : AuthorizeAttribute
{
public TransporterOnlyAttribute()
{
Policy = "Transporter";
}
}
Transporter with Customer Context
[AttributeUsage(AttributeTargets.Method)]
public sealed class TransporterWithCustomerAttribute : AuthorizeAttribute
{
public TransporterWithCustomerAttribute()
{
Policy = "TransporterWithCustomer";
}
}
Transporter with Company Context
[AttributeUsage(AttributeTargets.Method)]
public sealed class TransporterWithCompanyAttribute : AuthorizeAttribute
{
public TransporterWithCompanyAttribute()
{
Policy = "TransporterWithCompany";
}
}
Full Context Requirement
[AttributeUsage(AttributeTargets.Method)]
public sealed class TransporterWithCustomerAndCompanyAttribute : AuthorizeAttribute
{
public TransporterWithCustomerAndCompanyAttribute()
{
Policy = "TransporterWithCustomerAndCompany";
}
}
Safe Claim Access (Strongly Typed)
Never scatter FindFirst() across controllers.
public static class IdentityContext
{
public static int UserId(this ClaimsPrincipal user)
=> int.Parse(user.FindFirst("UserId")!.Value);
public static int? CustomerId(this ClaimsPrincipal user)
=> user.FindFirst("CustomerId") is { } c
? int.Parse(c.Value)
: null;
public static int? CompanyId(this ClaimsPrincipal user)
=> user.FindFirst("CompanyId") is { } c
? int.Parse(c.Value)
: null;
}
This:
Controller Usage (Method-Level Precision)
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
[HttpGet("customer")]
[TransporterWithCustomer]
public IActionResult GetCustomerOrders()
{
return Ok(new
{
UserId = User.UserId(),
CustomerId = User.CustomerId()
});
}
[HttpGet("company")]
[TransporterWithCompany]
public IActionResult GetCompanyOrders()
{
return Ok(new
{
UserId = User.UserId(),
CompanyId = User.CompanyId()
});
}
[HttpPost("full-context")]
[TransporterWithCustomerAndCompany]
public IActionResult CreateOrder()
{
return Ok(new
{
UserId = User.UserId(),
CustomerId = User.CustomerId(),
CompanyId = User.CompanyId()
});
}
}
Each endpoint clearly states:
Who can call it
What context is required
Important Architectural Rule
Authorization validates presence of context, not ownership.
Always enforce:
This belongs in the service layer, not authorization policies.
Why This Design Scales
✔ Clean separation of concerns
✔ No controller logic duplication
✔ Easy to extend (BranchId, RegionId, ProjectId)
✔ Works across microservices
✔ Fully testable
This pattern is used in enterprise SaaS, multi-tenant platforms, and cloud-native systems.
Final Thoughts
Attribute-based contextual authorization provides:
If your system handles multiple identities, tenants, or scopes, this approach is not optional—it is essential.