ASP.NET Core  

Building Context-Aware Authorization and Multi-Tenant Security in ASP.NET Core Microservices

Introduction

Authorization in modern ASP.NET Core applications is no longer about simple roles like Admin or User. Enterprise systems require context-aware authorization, where access decisions depend on who the user is, which customer they belong to, and which company or tenant they are operating under.

This article demonstrates a production-grade authorization architecture using:

  • JWT claims (UserId, CustomerId, CompanyId)

  • Attribute-based policies

  • Custom authorization handlers

  • Strongly typed claim access

  • Multi-tenant data isolation

  • Testable and reusable design

This approach is ideal for microservices, SaaS platforms, and B2B systems.

1. Context-Aware JWT Design

A secure JWT should carry authorization context, not UI state.

{
  "UserType": "Transporter",
  "UserId": "89",
  "CustomerId": "501",
  "CompanyId": "42",
  "exp": 1770228296
}

Each claim answers a question:

ClaimPurpose
UserIdWho is the caller
CustomerIdWhich customer scope
CompanyIdWhich tenant/company
UserTypeHigh-level access boundary

2. JWT Authentication Setup

JWT validation happens locally in every 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("StrongSecretKey"))
        };
    });

This removes runtime dependency on the authentication service and improves performance.

3. Authorization Policies (The Rules Engine)

Policies define what must be true for access.

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("UserOnly", policy =>
        policy.RequireClaim("UserId"));

    options.AddPolicy("UserWithCustomer", policy =>
        policy.RequireClaim("CustomerId"));

    options.AddPolicy("UserWithCompany", policy =>
        policy.RequireClaim("CompanyId"));

    options.AddPolicy("UserWithCustomerAndCompany", policy =>
        policy.RequireClaim("CustomerId")
              .RequireClaim("CompanyId"));
});

Policies are reusable, composable, and testable.

4. Attribute-Based API Contracts

Attributes expose authorization intent directly on endpoints.

User + Company Attribute

[AttributeUsage(AttributeTargets.Method)]
public sealed class UserWithCompanyAttribute : AuthorizeAttribute
{
    public UserWithCompanyAttribute()
    {
        Policy = "UserWithCompany";
    }
}

User + Customer + Company Attribute

[AttributeUsage(AttributeTargets.Method)]
public sealed class UserWithCustomerAndCompanyAttribute : AuthorizeAttribute
{
    public UserWithCustomerAndCompanyAttribute()
    {
        Policy = "UserWithCustomerAndCompany";
    }
}

This keeps controllers declarative and clean.

5. Strongly Typed Claim Access

Avoid repeating FindFirst() everywhere.

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 pattern:

  • Eliminates null bugs

  • Improves readability

  • Centralizes parsing logic

6. Custom IAuthorizationHandler (Enterprise Control)

Policies validate presence.
Handlers validate relationships.

Requirement

public sealed class CompanyAccessRequirement : IAuthorizationRequirement { }

Handler

public sealed class CompanyAccessHandler
    : AuthorizationHandler<CompanyAccessRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        CompanyAccessRequirement requirement)
    {
        var userId = context.User.FindFirst("UserId")?.Value;
        var companyId = context.User.FindFirst("CompanyId")?.Value;

        if (!string.IsNullOrEmpty(userId) &&
            !string.IsNullOrEmpty(companyId))
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

Registration

builder.Services.AddSingleton<IAuthorizationHandler, CompanyAccessHandler>();

This enables fine-grained, business-aware authorization.

7. Controller Usage (Method-Level Precision)

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    [HttpGet("company")]
    [UserWithCompany]
    public IActionResult GetCompanyOrders()
    {
        return Ok(new
        {
            UserId = User.UserId(),
            CompanyId = User.CompanyId()
        });
    }

    [HttpPost("secure")]
    [UserWithCustomerAndCompany]
    public IActionResult CreateOrder()
    {
        return Ok(new
        {
            UserId = User.UserId(),
            CustomerId = User.CustomerId(),
            CompanyId = User.CompanyId()
        });
    }
}

Controllers remain thin, focused only on application logic.

8. Multi-Tenant Data Isolation (EF Core)

Prevent cross-tenant data leaks using global query filters.

public class AppDbContext : DbContext
{
    private readonly IHttpContextAccessor _http;

    public AppDbContext(
        DbContextOptions options,
        IHttpContextAccessor http) : base(options)
    {
        _http = http;
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var companyId = _http.HttpContext?
            .User.FindFirst("CompanyId")?.Value;

        if (companyId != null)
        {
            modelBuilder.Entity<Order>()
                .HasQueryFilter(o =>
                    o.CompanyId == int.Parse(companyId));
        }
    }
}

This guarantees tenant isolation by default.

9. Unit Testing Authorization

[Fact]
public async Task Should_Authorize_When_CompanyId_Exists()
{
    var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
    {
        new Claim("UserId", "1"),
        new Claim("CompanyId", "42")
    }, "Test"));

    var requirement = new CompanyAccessRequirement();
    var context = new AuthorizationHandlerContext(
        new[] { requirement }, user, null);

    var handler = new CompanyAccessHandler();

    await handler.HandleAsync(context);

    Assert.True(context.HasSucceeded);
}

Authorization becomes fully testable.

10. Making It NuGet-Ready

This architecture can be packaged into a shared library:

  • Attributes

  • Policies

  • Handlers

  • Claim helpers

Usage becomes:

services.AddContextAuthorization();

Perfect for large microservice ecosystems.

Final Architecture Rule

Authentication proves identity
Authorization proves permission
Services enforce business rules

Never mix them.

Conclusion

By combining:

  • JWT claims

  • Attribute-based authorization

  • Custom handlers

  • Multi-tenant isolation

You get a secure, scalable, and enterprise-ready authorization system that works across microservices and teams.

This is the pattern used in real-world SaaS and enterprise platforms, not demos.