OpenID Connect for a multi-tenant web application: one issuer, many tenants
Every tenant wants to bring its own identity provider. The OIDC handler was designed for one. Here is how to make them agree without lying to the framework.
In the spring of 2016 a customer with four thousand employees asked us to sign their staff in with their own Azure AD. The product had one OpenID Connect configuration: one authority, one client id, one secret, all in web.config. We said yes, because you say yes to a customer that size, and then spent six weeks learning that the Katana middleware was written for one authority per pipeline.
The problem is not the protocol. OpenID Connect is perfectly happy with many issuers. The problem is that the framework, then and now, resolves an authentication handler's configuration by a name you chose at startup. Either you register something per tenant, or you lie to the framework about which configuration it is reading. I lied for years. I do not any more, and this sheet is the honest version.
The drawing below shows the redirect dance with one rule baked in: the tenant is resolved from the host before anything challenges. If you do not know the tenant, you do not know the authority, and you cannot build the redirect.
Two shapes of multi-tenant OIDC
Shape one: one issuer for everybody. Your own identity provider (Entra External ID, Keycloak, Duende, pick one) holds all the users, and the tenant travels as a claim inside the token. One authority, one set of validation parameters, one cookie. Most products should stop here. Tenant isolation is then an authorization problem, which is sheet I-03, and the OIDC part is what the template gives you.
Shape two: each tenant brings its own authority. Customer A has Entra, customer B has Okta, customer C has ADFS on a server in a cupboard. Now the authority, client id, client secret and valid issuers are per tenant, and they are data in your tenant store, not configuration in a file. This sheet is about shape two, because shape one needs no sheet.
One scheme per tenant, options from the store
Register one OIDC scheme per tenant, named after the tenant, and let the options system fill it from your store. The framework calls every IConfigureNamedOptions<OpenIdConnectOptions> with the scheme name when the handler first asks for its options; that name is our hook.
public sealed class TenantOidcOptions(ITenantStore store)
: IConfigureNamedOptions<OpenIdConnectOptions>
{
public void Configure(string? name, OpenIdConnectOptions o)
{
if (name is null || !name.StartsWith("oidc-")) return;
var t = store.GetIdp(name["oidc-".Length..]); // cached, singleton-safe
o.Authority = t.Authority;
o.ClientId = t.ClientId;
o.ClientSecret = t.ClientSecret; // a Key Vault reference, resolved here
o.ResponseType = OpenIdConnectResponseType.Code;
o.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
o.MapInboundClaims = false;
o.TokenValidationParameters.ValidIssuers = t.ValidIssuers;
o.TokenValidationParameters.NameClaimType = "name";
}
public void Configure(OpenIdConnectOptions o) { }
}
The class is a singleton, so it must not depend on anything scoped; the store behind it caches tenant records and is safe to call from here. When a tenant changes its identity provider, evict the entry with IOptionsMonitorCache<OpenIdConnectOptions>.TryRemove("oidc-acme") and the next request rebuilds it. In 2016 there was no options system to hook: I branched the OWIN pipeline with app.Map per tenant, and when that stopped scaling I rewrote the authority inside RedirectToIdentityProvider, which worked and fought me at every framework upgrade.
Then a policy scheme in front, so that the rest of the application never knows a tenant scheme exists:
var auth = builder.Services.AddAuthentication(o =>
{
o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
o.DefaultChallengeScheme = "oidc";
});
auth.AddCookie();
auth.AddPolicyScheme("oidc", "tenant-aware OIDC", o =>
{
o.ForwardDefaultSelector = ctx =>
"oidc-" + ctx.RequestServices.GetRequiredService<ITenantContext>().Id;
});
foreach (var id in tenantIdsKnownAtStartup)
auth.AddOpenIdConnect($"oidc-{id}", _ => { });
builder.Services.AddSingleton<IConfigureOptions<OpenIdConnectOptions>, TenantOidcOptions>();
[Authorize] challenges "oidc", the selector reads the tenant that middleware resolved from the host, and the handler for oidc-acme runs with acme's options. The selector applies to sign-out as well, so the end-session redirect also goes to the right authority. Tenants created after startup get their scheme through IAuthenticationSchemeProvider.AddScheme at the moment they are provisioned; I have done that up to about a hundred and forty tenants in one process and do not know where it stops.
The tenant claim and the callback
ValidIssuers proves the token came from an issuer you configured for acme. It does not prove it came from acme's directory rather than another tenant on the same identity provider, which matters for Entra, where every customer shares login.microsoftonline.com. So the last check happens in OnTokenValidated, inside the same Configure method, where t is still in scope:
o.Events.OnTokenValidated = ctx =>
{
var tid = ctx.Principal?.FindFirst("tid")?.Value;
if (t.EntraTenantId is not null && tid != t.EntraTenantId)
{
ctx.Fail("token issued for another directory");
return Task.CompletedTask;
}
ctx.Principal!.Identities.First().AddClaim(new Claim("tenant", t.Id));
return Task.CompletedTask;
};
The tenant claim we add is our own id, not theirs. Downstream code reads that claim and nothing else; nobody outside this file ever parses an issuer URL again. (The full story of the tid check is sheet I-05.)
The callback path matters more than people expect. The handler builds redirect_uri from the current request, so on acme.myapp.be it becomes https://acme.myapp.be/signin-oidc. When the identity provider sends the browser back there, the tenant resolver runs again on the same host, the policy scheme forwards to the same oidc-acme, and the correlation cookie the handler dropped on the way out is found on the way back. Each tenant's redirect URI has to be registered at their identity provider, which is a support ticket per tenant, and worth it.
Cookies across tenant subdomains
Cookies default to the host that set them, and that is the behaviour you want. A user on acme.myapp.be gets a cookie for acme.myapp.be; a consultant who works in three tenants gets three sessions in three tabs, and each one signs out independently. Do not set Cookie.Domain to .myapp.be unless you have a reason to share a session across tenants, and if you think you have one, draw it first, because you will also be sharing the sign-out and the sliding expiration.
Two things do have to be shared. The Data Protection key ring must be the same on every instance, in a blob container with a Key Vault key, or the cookie written by instance two is unreadable on instance five after a scale-out. And SameSite must allow the callback: the authorization code arrives by a cross-site request, so the handler's own correlation and nonce cookies are set to SameSite=None; Secure. Your session cookie can stay Lax, because by the time it is written the browser is on a same-site request again.
What I would build today
Shape one, until a paying customer asks for shape two. Then one scheme per tenant, options filled from the tenant store with the secret as a Key Vault reference, a policy scheme in front, the tid check in OnTokenValidated, and host-based tenants so the callback finds its own cookies. The trade-off I accept is a scheme registration per tenant and a redirect URI to register on each customer's side. What I refuse to trade is the custom IOptionsMonitor<OpenIdConnectOptions> I wrote after the move to ASP.NET Core, the one whose Get ignored the name it was given and read the tenant out of IHttpContextAccessor. It looked clever in 2017 and it cost more than every scheme registration since.
Drawn from
- Configure OpenID Connect Web (UI) authentication in ASP.NET Corelearn.microsoft.com
- Options pattern in .NETlearn.microsoft.com
- Use cookie authentication without ASP.NET Core Identitylearn.microsoft.com
- TokenValidationParameters classlearn.microsoft.com