DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

Microsoft Entra ID Multi‑Tenant SaaS + .NET 8 Web API — A Production‑Grade Playbook (3 Tenants, 3 Clients)

Microsoft Entra ID Multi‑Tenant SaaS + .NET 8 Web API — A Production‑Grade Playbook (3 Tenants, 3 Clients)

Microsoft Entra ID Multi‑Tenant SaaS + .NET 8 Web API — A Production‑Grade Playbook (3 Tenants, 3 Clients)

Most .NET devs can secure an API with Microsoft Entra ID (Azure AD).

But multi‑tenant is where the real SaaS problems start:

  • How do three different customer tenants call your API?
  • Who grants consent? What’s the “service principal” really doing?
  • Why do tokens validate in one tenant but fail in another?
  • How do you validate multiple issuers without accidentally accepting everyone?
  • How do you design onboarding so customers don’t get stuck in AADSTS errors?

This is a step‑by‑step, production‑ready multi‑tenant workflow + .NET 8 code that enforces:

  • correct audience
  • correct issuer
  • allowlisted tenant IDs
  • allowlisted client apps per tenant
  • proper roles vs scopes handling

And we’ll do it with three customers (Tenant A/B/C) consuming one API.


Table of Contents

  1. Mental model: Tenants, app objects, and why multi‑tenant is not “open to the world”
  2. Architecture: Provider tenant + 3 customer tenants
  3. Entra configuration (Provider): make the API multi‑tenant + scopes + app roles
  4. Entra configuration (Customer A/B/C): client apps + permissions + admin consent
  5. Onboarding workflow that actually works
  6. .NET 8 Minimal API: strict multi‑tenant token validation (issuer + tenant allowlist + client allowlist)
  7. Testing: Postman (delegated) and client_credentials (app‑only)
  8. Hard‑to‑find gotchas (the stuff that causes endless AADSTS errors)
  9. Production checklist

Mental model: Tenants, app objects, and why multi‑tenant is not “open to the world”

Tenants are security containers

A tenant is where Entra stores identities (users, apps, policies). Every organization has its own tenant and can enforce:

  • Conditional Access
  • consent policies
  • admin approval workflows
  • token lifetime / session behavior

Single‑tenant vs multi‑tenant: it’s about who can sign in

In Entra, an app can be configured as:

  • Single‑tenant: only users/apps from its home tenant can sign in.
  • Multi‑tenant: users/apps from other tenants can sign in after consent.

Multi‑tenant does not mean:

“Any tenant can call my API by default.”

It means:

“Other tenants can create a service principal for my app and consent to permissions so tokens can be issued to call it.”

The important objects

  • Application object: lives in the home tenant where you registered it.
  • Service principal: the representation of that app inside another tenant after consent.

This is why consent matters: without a service principal in the customer tenant, the customer can’t cleanly request tokens for your API in the real world.


Architecture: Provider tenant + 3 customer tenants

We’ll model a classic B2B SaaS:

  • Provider (you): Tenant-P
  • Customers:
    • Tenant-A (Customer A)
    • Tenant-B (Customer B)
    • Tenant-C (Customer C)

App registrations

Provider tenant (Tenant-P)

  • SaaS.Api (Web API registration)

Each customer tenant

  • CustA.Client (client app in Tenant-A)
  • CustB.Client (client app in Tenant-B)
  • CustC.Client (client app in Tenant-C)

Token story (what happens on every request)

Customer A gets an access token from Tenant-A:

  • iss → Tenant-A issuer
  • tid → Tenant-A id
  • audyour API App ID URI
  • azp/appid → Customer A’s client app id
  • scp or roles → permissions

Your API must decide:

  • Is this token really for me (aud)?
  • Is this tenant allowed (tid, iss)?
  • Is this calling app allowed (azp)?
  • Does it have the required permission (scp or roles)?

Entra configuration (Provider): make the API multi‑tenant + scopes + app roles

Do this in Tenant-P.

1) Register the API

  • Entra admin center → App registrations → New registration
  • Name: SaaS.Api
  • Supported account types: Accounts in any organizational directory (multi‑tenant)

2) Expose an API (App ID URI + scopes)

Go to Expose an API.

Set Application ID URI:

  • Recommended (easy uniqueness): api://<API_CLIENT_ID>
  • If you prefer a URL: https://yourcompany.onmicrosoft.com/saasapi (must be globally unique for multi‑tenant)

Add scopes (delegated permissions):

  • api.read
  • api.write

3) Add App Roles (application permissions for daemon/server calls)

Go to App roles and add roles like:

  • Display: API Reader (App)

    • Allowed member types: Applications
    • Value: Api.Read.All
  • Display: API Writer (App)

    • Allowed member types: Applications
    • Value: Api.Write.All

Why roles?

For client_credentials, Entra issues roles, not scp. Your clients will request .default.

4) Optional: knownClientApplications (multi-tier consent in one step)

If you own both a client and the API (two registrations) and want one‑click consent, you can put the client AppId into the API manifest:

"knownClientApplications": [
  "12ab34cd-56ef-78gh-90ij11kl12mn"
]
Enter fullscreen mode Exit fullscreen mode

This enables “multi-tier consent” so the API can be consented along with the client in a single consent experience.


Entra configuration (Customer A/B/C): client apps + permissions + admin consent

Each customer does this in their tenant (Tenant-A/B/C).

1) Register a client app

Example in Tenant-A:

  • App registration: CustA.Client

Choose platform based on client type:

  • Confidential client (backend/daemon): uses secret/cert
  • SPA (React/Angular): uses PKCE, no secret in browser

2) Add API permissions

  • API permissions → Add a permission → My APIs → select SaaS.Api
  • Choose Delegated scopes OR Application roles

Examples:

  • Delegated: api.read
  • Application: Api.Read.All

3) Grant admin consent

  • Click Grant admin consent

This is the step that creates:

  • service principal of SaaS.Api in the customer tenant
  • consent record/assignments

If customer admin blocks user consent, this is mandatory.


Onboarding workflow that actually works

What your SaaS should provide to customers

When you ship multi‑tenant B2B SaaS, your onboarding doc should include:

  • Your API Application ID URI (audience)
  • Your required scopes/roles
  • The admin consent URL
  • A “test call” guide (curl/Postman)

Admin consent URL (recommended for SaaS sign‑up)

Send the customer admin to:

https://login.microsoftonline.com/common/adminconsent
  ?client_id=<YOUR_API_OR_SIGNUP_CLIENT_APPID>
  &redirect_uri=<YOUR_ONBOARDING_CALLBACK_URL>
Enter fullscreen mode Exit fullscreen mode

If you don’t support Microsoft personal accounts, you can use /organizations instead of /common.

After consent: store tenant metadata

After the admin consents, you should persist:

  • tid (tenant ID)
  • allowed client apps (optional, but recommended)
  • tenant config (db connection, feature flags, limits)

This becomes your tenant allowlist.


.NET 8 Minimal API: strict multi‑tenant token validation (issuer + tenant allowlist + client allowlist)

We’ll build a Minimal API that accepts tokens from multiple tenants but only allows:

  • Tenant A/B/C
  • specific client apps per tenant
  • api.read scope OR Api.Read.All role

Create project

dotnet new webapi -n MultiTenantSaaS.Api
cd MultiTenantSaaS.Api
dotnet add package Microsoft.Identity.Web
Enter fullscreen mode Exit fullscreen mode

appsettings.json

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "TenantId": "organizations",
    "ClientId": "YOUR_API_APPID",
    "Audience": "api://YOUR_API_APPID"
  },
  "Tenancy": {
    "AllowedTenants": [ "TENANT_A", "TENANT_B", "TENANT_C" ],
    "AllowedClientAppsByTenant": {
      "TENANT_A": [ "CUSTA_CLIENT_APPID" ],
      "TENANT_B": [ "CUSTB_CLIENT_APPID" ],
      "TENANT_C": [ "CUSTC_CLIENT_APPID" ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Program.cs (full)

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"),
        jwtBearerOptions =>
        {
            // Audience validation: do not skip this
            var audience = builder.Configuration["AzureAd:Audience"];
            if (!string.IsNullOrWhiteSpace(audience))
            {
                jwtBearerOptions.TokenValidationParameters.ValidAudience = audience;
            }

            // Multi-tenant: we accept many issuers but will validate them ourselves
            jwtBearerOptions.TokenValidationParameters.ValidateIssuer = false;
        });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ReadAccess", policy =>
    {
        policy.RequireAuthenticatedUser();
        // We'll do fine-grained permission checks during token validation
    });
});

var app = builder.Build();

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();

// Public
app.MapGet("/api/public", () => Results.Ok(new { Message = "Public", Time = DateTime.UtcNow }));

// Secured (multi-tenant checks applied in middleware event below)
app.MapGet("/api/secure", (ClaimsPrincipal user) =>
{
    var tid = user.FindFirst("tid")?.Value;
    var iss = user.FindFirst("iss")?.Value;
    var caller = user.FindFirst("azp")?.Value ?? user.FindFirst("appid")?.Value;

    var scopes = user.FindFirst("scp")?.Value;
    var roles = user.FindAll("roles").Select(r => r.Value).ToArray();

    return Results.Ok(new
    {
        Message = "Secure multi-tenant endpoint",
        TenantId = tid,
        Issuer = iss,
        CallerApp = caller,
        Scopes = scopes,
        Roles = roles
    });
}).RequireAuthorization("ReadAccess");

app.Run();

// ----------------------
// Multi-tenant token gate
// ----------------------
static class MultiTenantGate
{
    public static void AddMultiTenantValidation(WebApplicationBuilder builder)
    {
        builder.Services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
        {
            options.Events ??= new JwtBearerEvents();

            options.Events.OnTokenValidated = ctx =>
            {
                var config = builder.Configuration;

                var allowedTenants = config.GetSection("Tenancy:AllowedTenants").Get<string[]>() ?? Array.Empty<string>();
                var allowedClients = config.GetSection("Tenancy:AllowedClientAppsByTenant")
                                           .Get<Dictionary<string, string[]>>() ?? new();

                var principal = ctx.Principal!;
                var tid = principal.FindFirst("tid")?.Value;
                if (string.IsNullOrWhiteSpace(tid) || !allowedTenants.Contains(tid, StringComparer.OrdinalIgnoreCase))
                    ctx.Fail($"Tenant '{tid}' is not allowed.");

                // Issuer pattern (v2): https://login.microsoftonline.com/{tid}/v2.0
                var iss = principal.FindFirst("iss")?.Value;
                var expectedIssuer = $"https://login.microsoftonline.com/{tid}/v2.0";
                if (!string.Equals(iss, expectedIssuer, StringComparison.OrdinalIgnoreCase))
                    ctx.Fail("Invalid issuer for tenant.");

                // Calling app id (v2 uses azp for OBO/app; appid sometimes appears)
                var azp = principal.FindFirst("azp")?.Value ?? principal.FindFirst("appid")?.Value;
                if (string.IsNullOrWhiteSpace(azp))
                    ctx.Fail("Missing calling application id (azp/appid).");

                if (!allowedClients.TryGetValue(tid!, out var allowedClientApps) ||
                    !allowedClientApps.Contains(azp, StringComparer.OrdinalIgnoreCase))
                    ctx.Fail($"Client app '{azp}' is not allowed for tenant '{tid}'.");

                // Permission model:
                // Delegated => scp (space-separated scopes)
                // App-only  => roles
                var scp = principal.FindFirst("scp")?.Value ?? "";
                var scopes = scp.Split(' ', StringSplitOptions.RemoveEmptyEntries);

                var roles = principal.FindAll("roles").Select(c => c.Value).ToArray();

                var hasRead =
                    scopes.Contains("api.read", StringComparer.OrdinalIgnoreCase)
                    || roles.Contains("Api.Read.All", StringComparer.OrdinalIgnoreCase);

                if (!hasRead)
                    ctx.Fail("Missing required permission (api.read scope or Api.Read.All role).");

                return Task.CompletedTask;
            };
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Important: call the gate once, right after building builder and before builder.Build():

MultiTenantGate.AddMultiTenantValidation(builder);

Why this design is “production-grade”

  • Audience validation stays strict (prevents token reuse across resources).
  • Issuer validation is custom: we allow multiple tenants but still verify iss matches the tenant in tid.
  • Tenant allowlist is your real authorization boundary.
  • Client allowlist per tenant prevents “any app in the allowed tenant” from calling you.
  • roles vs scp is handled correctly.

Testing: Postman (delegated) and client_credentials (app‑only)

App‑only test (recommended for SaaS server-to-server)

Customer A requests token from their tenant:

Token URL

https://login.microsoftonline.com/<TENANT_A>/oauth2/v2.0/token
Enter fullscreen mode Exit fullscreen mode

Body (x-www-form-urlencoded)

  • client_id=<CUSTA_CLIENT_APPID>
  • client_secret=<secret>
  • grant_type=client_credentials
  • scope=api://<YOUR_API_APPID>/.default

Call API:

  • Authorization: Bearer <token>

Delegated test (Authorization Code)

If you test with Postman:

  • you must configure Redirect URI:
    • https://oauth.pstmn.io/v1/callback

If you get:

  • AADSTS500113: missing reply URL (Redirect URI not registered)
  • AADSTS7000218: missing client_secret or client_assertion (confidential client without auth)

Hard‑to‑find gotchas (the stuff that causes endless AADSTS errors)

1) App ID URI uniqueness for multi‑tenant

For single‑tenant you can get away with “unique inside your tenant”.

For multi‑tenant, your App ID URI must be globally unique, or switching to multitenant can fail.

2) /common vs /organizations

  • /common: org accounts + personal Microsoft accounts (if enabled)
  • /organizations: only work/school accounts (typical B2B SaaS)

If you don’t support personal accounts, prefer /organizations.

3) Token validation in multi‑tenant is stricter than “turn off issuer”

If you set ValidateIssuer=false without replacement checks, you can accidentally accept tokens from any tenant that can obtain them.

Replace it with tenant allowlisting + issuer pattern validation.

4) Consent can be blocked by customer policies

Customers may:

  • disable user consent
  • require admin approval
  • enforce Conditional Access that blocks “unmanaged” flows

Plan for admin‑driven onboarding.

5) National clouds: multi‑tenant doesn’t cross clouds

A multi‑tenant app in commercial cloud can be added to other commercial tenants, but not into Azure Government tenants, etc. (cloud boundaries matter).

6) MSAL cache gotcha with /common

If a user signs in via /common, MSAL caches under the actual tenant, and repeating token requests to /common can miss cache and re-prompt.

For an already-known user/tenant, switch to the tenant endpoint.

7) knownClientApplications is powerful but easy to misunderstand

It doesn’t “auto‑trust” the client. It improves the consent experience for multi‑tier apps under the same publisher.


Production checklist

Entra (Provider)

  • [ ] API app registration set to multi‑tenant
  • [ ] Application ID URI set and stable
  • [ ] Delegated scopes created (api.read, api.write)
  • [ ] App roles created (Api.Read.All, Api.Write.All) for app‑only
  • [ ] If multi‑tier: knownClientApplications considered

Entra (Customer A/B/C)

  • [ ] Client app registration created (SPA or confidential)
  • [ ] Permissions added (delegated scopes or app roles)
  • [ ] Admin consent granted
  • [ ] Client credentials stored securely (prefer certs/managed identity where possible)

API (.NET)

  • [ ] Validates audience
  • [ ] Validates issuer via tenant allowlist + expected issuer pattern
  • [ ] Validates tenant (tid)
  • [ ] Validates client app (azp/appid) allowlist per tenant
  • [ ] Enforces permissions (scp or roles)
  • [ ] Logs rejections safely (no secrets)

Wrap-up

Multi‑tenant SaaS with Entra ID is not “flip the multitenant switch.”

It’s a choreography of:

  • consent
  • service principals
  • token issuer validation
  • audience validation
  • tenant + client allowlisting
  • roles vs scopes
  • customer policy variability (CA, consent restrictions)

If you nail these, you have a repeatable onboarding and a secure API boundary that scales from 3 customers to 3,000.

Happy building — and may your 401s always be intentional. 🔐

Top comments (1)

Collapse
 
shemith_mohanan_6361bb8a2 profile image
shemith mohanan

This is gold for anyone building real SaaS on Entra 👌
Loved how clearly you explained the tenant vs issuer vs client app story — especially the allowlist approach. The onboarding + .NET 8 validation flow feels very practical and production-ready. Bookmarked for future reference.