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:

We will:

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:

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:

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

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.