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
- Mental model: Tenants, app objects, and why multi‑tenant is not “open to the world”
- Architecture: Provider tenant + 3 customer tenants
- Entra configuration (Provider): make the API multi‑tenant + scopes + app roles
- Entra configuration (Customer A/B/C): client apps + permissions + admin consent
- Onboarding workflow that actually works
- .NET 8 Minimal API: strict multi‑tenant token validation (issuer + tenant allowlist + client allowlist)
- Testing: Postman (delegated) and client_credentials (app‑only)
- Hard‑to‑find gotchas (the stuff that causes endless AADSTS errors)
- 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 -
aud→ your API App ID URI -
azp/appid→ Customer A’s client app id -
scporroles→ 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 (
scporroles)?
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.readapi.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"
]
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.Apiin 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>
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.readscope ORApi.Read.Allrole
Create project
dotnet new webapi -n MultiTenantSaaS.Api
cd MultiTenantSaaS.Api
dotnet add package Microsoft.Identity.Web
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" ]
}
}
}
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;
};
});
}
}
Important: call the gate once, right after building
builderand beforebuilder.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
issmatches the tenant intid. - 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
Body (x-www-form-urlencoded)
client_id=<CUSTA_CLIENT_APPID>client_secret=<secret>grant_type=client_credentialsscope=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:
knownClientApplicationsconsidered
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 (
scporroles) - [ ] 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)
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.