Archiboard

Set I · Identity & Access

Entra ID multi-tenant applications and the admin consent dance

One app registration, any customer directory. The consent flow is well documented. What to trust when the music stops is not.

In February 2023 a customer with six hundred employees gave us one requirement for sign-in: "with our Microsoft accounts, and you must not ask our people for consent one by one". Their IT manager wanted a single screen, a single click, and to never hear about it again. That is admin consent, and when it works it is the nicest onboarding flow a business application has. The portal still said Azure AD at the time, which tells you how much of the naming in this sheet has been through the wash.

It is also where we shipped a bug that let any Entra tenant on the planet sign in to our application. The line was ValidateIssuer = false, copied from a forum answer, and it lived in production for eight months. Nobody used it, as far as the logs show. That is not a defence, and the 2026 revision of this sheet exists because a reviewer found the same line in the sample code below.

The dance itself has three partners, the customer's directory, Entra's login service and your application, and the choreography is easy. What is hard is deciding, when the music stops, which claim says "this person belongs to customer X". The answer is the tid claim, checked against a link table you own. Not the issuer alone, and never the query string.

The admin consent flow between the customer tenant, Entra ID and your application customer's Entra tenant Entra ID your product Connect Microsoft 365 GET /adminconsent client_id, scope, state consent screen admin approves signs in service principal created redirect_uri callback admin_consent=True&tenant= sign in, read tid id_token with tid /authorize id_token tenant links acme -> tid tenant= is unauthenticated; trust only the tid claim
Fig. 1. The admin consent dance. The tenant value that comes back on the redirect is a hint; the tid claim in a validated id_token is the fact, and it only means something once it is in your link table.

Registering the app once

A multi-tenant registration is a single app in your own Entra tenant with "Accounts in any organizational directory" selected. Not "and personal Microsoft accounts": a business application has no use for Xbox logins, and allowing them changes which authority you can use later. The Application ID URI must be globally unique, so it has to be under a domain you verified, and every redirect URI you will ever use goes in the list, including the one for admin consent.

The permissions you request are what the IT manager reads on the consent screen, one line each. For sign-in you need openid, profile, email and User.Read, and nothing else. Application permissions, the ones that let your service read the directory without a user present, are only for products that genuinely sync from Graph; every one you add that you do not need is a phone call from a security officer, and I have taken those calls.

/common, /organizations and the templated issuer

A single-tenant app points its authority at https://login.microsoftonline.com/{your-tenant}/v2.0. A multi-tenant app cannot, because it does not know the tenant before the user signs in. Entra offers two tenant-independent authorities: /common, which accepts work accounts and personal accounts, and /organizations, which accepts work accounts only. Use /organizations.

Neither is an issuer. The discovery document at /organizations/v2.0/.well-known/openid-configuration returns "issuer": "https://login.microsoftonline.com/{tenantid}/v2.0", a template with the braces left in. The stock OIDC handler compares the token's iss to that string, finds no match, and throws SecurityTokenInvalidIssuerException. Every forum answer to that exception is ValidateIssuer = false. That is the bug from the opening, and the correct fix is an issuer validator that ties iss to tid:

auth.AddOpenIdConnect("entra", o =>
{
    o.Authority = "https://login.microsoftonline.com/organizations/v2.0";
    o.ClientId = cfg["Entra:ClientId"];
    o.ClientSecret = cfg["Entra:ClientSecret"];
    o.ResponseType = OpenIdConnectResponseType.Code;
    o.MapInboundClaims = false;
    o.TokenValidationParameters.IssuerValidator = (issuer, token, _) =>
    {
        if (token is not JsonWebToken jwt
            || !jwt.TryGetClaim("tid", out var tid)
            || !Guid.TryParse(tid.Value, out _)
            || issuer != $"https://login.microsoftonline.com/{tid.Value}/v2.0")
            throw new SecurityTokenInvalidIssuerException("issuer does not match tid");
        return issuer;
    };
});

The 2023 draft used Microsoft.Identity.Web, whose AadIssuerValidator does exactly this plus the signing-key issuer check, and I still recommend it for a production Entra integration. The plain handler is shown here so the mechanism is visible. Either way, what the validator proves is that the token came from some Entra tenant and was not forged. It says nothing about whether that tenant is your customer.

The dance, step by step

An administrator of the customer, already signed in to your product as an admin of the tenant acme (by whatever door sheet I-01 chose), clicks "Connect Microsoft 365". You redirect them to https://login.microsoftonline.com/organizations/v2.0/adminconsent with your client id, the scope https://graph.microsoft.com/.default, the redirect URI, and a state you can verify later. The docs say to use organizations or the tenant id here, never common, because personal accounts cannot grant admin consent.

Entra asks the administrator to sign in, shows every permission you registered, and on approval creates a service principal for your app inside their tenant. Then it redirects back to you with admin_consent=True&tenant=<guid>&state=.... The Microsoft page carries a warning in bold: never use that tenant value to authenticate or authorize. Anyone can type that URL. Treat it as a notification that something may have happened, and go and find out what.

public string BuildConsentUrl(Guid tenantId, string userId)
{
    var state = protector.Protect($"{tenantId}|{userId}|{Guid.NewGuid():N}");
    var query = new Dictionary<string, string?>
    {
        ["client_id"] = clientId,
        ["scope"] = "https://graph.microsoft.com/.default",
        ["redirect_uri"] = redirectUri,
        ["state"] = state
    };
    return QueryHelpers.AddQueryString(
        "https://login.microsoftonline.com/organizations/v2.0/adminconsent", query);
}

The state is protected with Data Protection and carries your own tenant id and the user who started the dance, so the callback cannot be replayed into a different tenant by someone who copied the URL. On the callback we unprotect it, confirm it matches the signed-in user, and then start a normal OpenID Connect sign-in. The id_token that comes back has a tid claim that Entra signed, and only now do we write the link.

Mapping tid to your tenant, and the trap

The link table is small: your tenant id, Entra tid, who linked it, when. From then on, every sign-in through the entra scheme looks the tid up in that table. A hit gives you your own tenant, which becomes the tenant claim that the authorization sheet (I-03) reads. A miss is a directory that never consented, or a directory whose admin revoked the consent last week, and the right answer is a friendly page, not a 500.

o.Events.OnTokenValidated = async ctx =>
{
    var tid = ctx.Principal!.FindFirst("tid")!.Value;
    var links = ctx.HttpContext.RequestServices.GetRequiredService<ITenantLinks>();
    var linked = await links.FindByTidAsync(tid);
    if (linked is null)
    {
        ctx.Fail("directory not connected");          // OnRemoteFailure shows the page
        return;
    }
    ctx.Principal.Identities.First()
        .AddClaim(new Claim("tenant", linked.Id.ToString()));
};

This lookup is the check that ValidateIssuer = false skipped and that a correct issuer validator still does not perform. A valid token from a random Entra tenant is still a valid token. The issuer proves who signed it; the link table proves who you invited. Two more traps live nearby. Guests: a user from tenant Z invited into acme's directory signs in with tid equal to acme and idp equal to Z. That is acme's decision and you honour it. Holdings: one Entra tenant sometimes maps to several of your tenants, one per subsidiary, so the table is many-to-many and the sign-in shows a chooser when there is more than one hit.

What I would sign off today

The /organizations authority, an issuer validator that binds iss to tid (or Microsoft.Identity.Web, which brings one), a link table populated only from a validated token and never from the redirect, a protected state that names your own tenant, and the smallest permission list the product can live with. Add a "disconnect" button that deletes the link, because the customer's admin can revoke consent from their side at any time and you will only find out when the tokens stop. The trade-off I accept is the extra sign-in after consent, which costs the administrator one more click; the alternative is trusting a query string, and I have already paid for that once.

Drawn from