Featured image of post Dual Authentication in .NET 9: Integrating Internal and External Users

Dual Authentication in .NET 9: Integrating Internal and External Users

Authentication is hard. Multi-tenant authentication is harder.

Here is the scenario: You have a B2B SaaS application.

  1. You have your Internal Staff (Support, Ops, etc.) who live in your corporate Azure AD tenant.
  2. You have your External Customers, who might be logging in via their own methods, or perhaps a separate Multi-Tenant Azure AD configuration.

You want a single API to serve both. Internal users should be able to do admin tasks; external customers should only see their own data. Sounds simple, right?

Well, technically it is, but finding the right documentation can be a nightmare.

The Solution: Multiple Authentication Schemes

ASP.NET Core (and .NET 9 broadly) handles this surprisingly well. You can register multiple JWT Bearer handlers and then use Authorization Policies to pick the right one.

Here is how we set this up.

Step 1: Registering the Schemes

In your Program.cs, you don’t just call AddJwtBearer once. You call it twice (or more), each time with a unique Scheme Name.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 1. Configure Internal Corporate Users (Single Tenant)
builder.Services.AddAuthentication()
    .AddJwtBearer("LocalUsers", options => // <--- Give it a name!
    {
        var params = configuration.GetSection("AzureAd:Internal");
        options.Authority = $"{params["Instance"]}{params["TenantId"]}/v2.0";
        // ... standard boring validation logic
    })
// 2. Configure External Customer Users (Multi-Tenant)
    .AddJwtBearer("ExternalUsers", options => // <--- Another name!
    {
        options.Authority = "https://login.microsoftonline.com/common/v2.0";
        // ... more validation logic
    });

Step 2: Defining the Policies

Now comes the fun part. We define clear policies that specify which schemes are valid for them.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("InternalOnly", policy => 
        policy.RequireAuthenticatedUser()
              .AddAuthenticationSchemes("LocalUsers")) // Only internal folks allowed
              
    .AddPolicy("CustomersOnly", policy => 
        policy.RequireAuthenticatedUser()
              .AddAuthenticationSchemes("ExternalUsers"))
              
    .AddPolicy("Everyone", policy => 
        policy.RequireAuthenticatedUser()
              .AddAuthenticationSchemes("LocalUsers", "ExternalUsers")); // Come one, come all

Step 3: Protecting the Endpoints

Finally, in your Controllers, you just use the standard [Authorize] attribute with your custom policy names.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    // Both internal support staff and the customer can view orders
    [HttpGet]
    [Authorize(Policy = "Everyone")]
    public IActionResult GetOrders() { ... }

    // Only internal staff can force-approve a refund. 
    // If a customer tries this -> 403 Forbidden.
    [HttpPost("refund")]
    [Authorize(Policy = "InternalOnly")]
    public IActionResult ApproveRefund() { ... }
}

Conclusion

This approach saves you from the mess of trying to merge two different identity providers into one logical “User”. It keeps the concerns separate, the configuration clean, and I can finally sleep at night knowing my internal admin endpoints are safe.

All rights reserved
Built with Hugo
Theme Stack designed by Jimmy