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:
UserIdCustomerIdCompanyIdUserType
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:
No network dependency on the auth service
High performance
Predictable authorization behavior
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:
Prevents null reference bugs
Improves readability
Centralizes parsing logic
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:
User belongs to company
Company belongs to customer
Operation is allowed for this relationship
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:
Explicit intent
Centralized security rules
Clean controllers
High scalability
If your system handles multiple identities, tenants, or scopes, this approach is not optional—it is essential.

Join the conversation! Your thoughts help the community grow.