Cybersecurity · September 17, 2026 · intSignal Team

Entra ID Single Sign-On for Multi-Tenant Apps: The Checks That Matter

Share this article

We've built Microsoft sign-in into two production applications. One is our customer portal, where every customer organization signs in with its own Microsoft Entra ID directory. The other is an operations platform that only our own directory can sign in to. Between them they cover both ways an app can be registered, single-tenant and multi-tenant.

Microsoft's identity platform does the hard part: passwords, multi-factor authentication, Conditional Access and token signing. What's left to the application is a short list of checks and decisions, and that's where most integration mistakes are made. This guide is that list, with Microsoft's own documentation quoted where it matters, and a tested token verifier you can use.

What does Entra ID single sign-on prove?

A verified ID token proves who signed in and which Microsoft Entra directory authenticated them. It doesn't say whether that person should get into your application. That second decision belongs to your app: which organizations you serve, which people in them you've invited, and what they're allowed to do. Keeping the two apart is the design principle behind everything below.

Single-tenant or multi-tenant?

The choice is made in the app registration, under Authentication and Supported account types, and it changes which endpoint you use and how you check tokens.

Single-tenantMulti-tenant
Supported account typesAccounts in this organizational directory onlyAccounts in any organizational directory
Who can sign inPeople in your own directoryPeople in any Entra directory whose organization has consented
Endpointshttps://login.microsoftonline.com/<your tenant ID>/...https://login.microsoftonline.com/organizations/...
Issuer checkOne fixed issuerBuilt from the tenant ID inside each token
Deciding who gets inYour directory, plus your own role assignmentsYour list of customer organizations, plus invitations within each
Good forInternal tools and staff-only admin consolesCustomer portals and B2B SaaS

Microsoft offers two authorities for multi-tenant apps: common, which also accepts personal Microsoft accounts, and organizations, which accepts work and school accounts only. If you don't support personal accounts, use organizations.

The sign-in flow, step by step

Both of our apps use the authorization code flow with PKCE, with the application acting as a confidential client: the server redeems the code with its own credential, so the browser never handles Microsoft's tokens.

  1. Start sign-in. Generate three random values: state, nonce and a PKCE code_verifier. Store them server-side, or in a signed, HttpOnly cookie, so they can be used exactly once.
  2. Redirect to the authorize endpoint with client_id, response_type=code, your exact redirect_uri, scope=openid profile email, state, nonce, code_challenge and code_challenge_method=S256. The profile scope matters: Microsoft only includes the oid claim when it's requested.
  3. On the callback, check state first. It must equal the value you stored for this browser. Then discard the stored values, whatever happens next.
  4. Redeem the code at the token endpoint with the code, the same redirect_uri, the code_verifier, and your client credential.
  5. Verify the ID token, including the nonce. The verifier below does this.
  6. Decide access: is this organization one of your customers, and has this person been invited?
  7. Start a fresh session. Issue a new session ID rather than reusing the pre-login one, so a session ID planted before sign-in can't become an authenticated session.

PKCE was designed for apps that can't keep a secret, so it's reasonable to ask why a server-side app needs it. The IETF's current OAuth security guidance, RFC 9700, answers that: "For confidential clients, the use of PKCE is RECOMMENDED, as it provides strong protection against misuse and injection of authorization codes." It costs a few lines, and we use it in both apps.

The token checks, and why each one matters

CheckWhat to compareWhat it stops
SignatureAgainst Microsoft's published signing keys, selected by the token's kidForged tokens
AlgorithmAccept RS256 onlyTokens that name a weaker or unsigned algorithm
Audience (aud)Your application (client) IDA token issued to a different app being replayed at yours
Expiry (exp, nbf)Current time, with a small allowance for clock skewOld tokens
Issuer (iss)https://login.microsoftonline.com/<tid>/v2.0, using the tid from the same tokenTokens from another issuer, or with a tenant that doesn't match
Tenant (tid)Your own tenant ID, or your list of customer tenantsEvery other Entra directory in the world
NonceThe value you stored when sign-in startedA captured ID token being replayed into a new sign-in
Object ID (oid)PresentTokens you can't tie to a stable identity

Microsoft rotates its signing keys, so fetch them from the keys endpoint and cache them, rather than saving a copy. A good JWT library refetches when it sees a kid it doesn't know.

The multi-tenant issuer check

This is the check most worth slowing down for. If you load the OpenID Connect metadata for the organizations or common endpoint, the issuer it publishes is a template, not a value:

https://login.microsoftonline.com/{tenantid}/v2.0

Microsoft's documentation for multi-tenant apps explains what to do with it: "The application must validate that the issuer property in the published metadata matches the iss claim in the token, in addition to the usual check that the iss claim in the token contains the tenant ID (tid) claim."

In practice: substitute the token's own tid into the template, and require the token's iss to equal the result exactly. Microsoft's own authentication libraries for web apps include an issuer validator that handles the template. A general-purpose JWT library that only accepts a fixed issuer string can't, so either skip its issuer option and make the comparison yourself, as the verifier below does, or give it a function that builds the expected issuer from the token.

The issuer check proves the token is internally consistent. It doesn't limit who can sign in: any organization whose users can consent to your app can produce a valid token for it. The tenant check is what limits access to your customers.

Which claim identifies the user?

Use tid and oid together. Microsoft's documentation is explicit about why.

On email: "This value isn't guaranteed to be correct and is mutable over time. Never use it for authorization or to save data for a user." On preferred_username: "Its value is mutable and might change over time. Since it's mutable, this value can't be used to make authorization decisions."

The oid is the immutable ID of the user object, and it's the same for every application the person signs in to. It's only unique within its directory, though: "If a single user exists in multiple tenants, the user contains a different object ID in each tenant." Store the pair, and treat name, email and preferred_username as display values you refresh at each sign-in.

The sub claim is also stable, but it's pairwise: each application receives a different sub for the same person. That's fine if your app is the only thing that will ever need the identifier, and awkward the day a second service needs to recognize the same user. oid avoids that.

Mapping a Microsoft directory to a customer

In a multi-tenant app, the tenant check needs a list: which Entra tenant ID belongs to which customer. Microsoft's admin consent flow is the natural way to onboard one. You send the customer's administrator a link to the admin consent endpoint, they approve your app for their organization, and Microsoft redirects back to you with the result:

https://your-app.example.com/callback?admin_consent=True&tenant=<tenant ID>&state=<your state value>

It's tempting to save that tenant value against the customer and move on. Microsoft's documentation warns against exactly that: "Never use the tenant ID value of the tenant parameter to authenticate or authorize users. The tenant ID value can be updated and sent by bad actors to impersonate a response to your app." The redirect is just a URL in a browser, and anyone can type one.

A safe onboarding flow looks like this:

  1. Generate the link from a signed-in staff session. Put a random, single-use value in state, store it with the customer record and the staff member who created it, and give it an expiry.
  2. When the redirect arrives, look up that value. Unknown, expired or already-used values are rejected. A customer ID in state isn't enough, because it can be guessed or copied.
  3. Take the tenant ID from a verified token, not from the URL. Have the customer's administrator complete a normal sign-in, and bind the tid from their verified ID token. Treat the tenant parameter as a hint to compare against.
  4. Don't silently replace an existing binding. If the customer is already linked to a tenant, a change should need a deliberate staff action.
  5. Record it. Who linked which tenant to which customer, and when.

Deciding who gets in

A valid token from a customer's tenant still shouldn't create an account automatically. Everyone in that organization's directory can produce one, including people who have nothing to do with your service. Both of our apps are invitation-only: a token proves identity, and an invitation grants access.

There are two ways to address an invitation:

  • By tenant ID and object ID. The strictest option: the invitation names exactly one directory object, and only that object can redeem it. It needs a way to look up the person's oid first, such as a people picker backed by Microsoft Graph, or having them sign in once.
  • By email address, within a linked tenant. More convenient, since administrators know email addresses. Keep it safe by only matching invitations inside the tenant the customer has linked, making each invitation single-use with an expiry, and binding the oid when it's redeemed. From then on, recognize the person by tid and oid only, never by email again.

Whichever you choose, deny clearly and log why: the organization isn't linked, the person isn't invited, or the account is disabled. Those reasons answer nearly every "I can't sign in" ticket.

Check access on every request, not only at sign-in. When a customer disables someone in Microsoft Entra ID, that person can't sign in again, but a session your app issued an hour ago is still yours to end. Load the user's status and roles with each request, so removing access in your app takes effect immediately, and keep session lifetimes modest.

Certificates instead of client secrets

The token request needs a client credential. A client secret is a shared password, stored in your configuration and sent with every request. A certificate credential keeps the private key on your server, and sends a short-lived signed assertion instead. Both of our apps support certificates and prefer them.

Microsoft's current format for the assertion:

PartValue
Header algPS256
Header typJWT
Header x5t#S256Base64url-encoded SHA-256 thumbprint of the certificate's DER encoding
audThe token endpoint you're sending the request to
iss and subYour application (client) ID
jtiA new unique value for every assertion
nbf and iatNow
expMicrosoft's guidance is to keep it short: 5 to 10 minutes after nbf at most

Send it as client_assertion, with client_assertion_type set to urn:ietf:params:oauth:client-assertion-type:jwt-bearer, in place of client_secret.

The thumbprint is where it usually goes wrong. The value in the header is base64url of the raw hash bytes, not the hexadecimal string you'd copy from a certificate viewer. Computing it from the certificate file, as the code below does, avoids the conversion entirely.

Rotation is straightforward: an app registration can hold more than one certificate, so upload the new one, switch your app to it, confirm sign-ins work, then remove the old one before it expires.

A tested verifier you can use

This module has two functions. entraTokenVerifier applies every check in the table above, for either a single-tenant or a multi-tenant app. clientAssertion builds the certificate assertion for the token request.

It needs Node.js 18 or later and one dependency, the jose library:

npm install jose
// entra-auth.mjs: verify Microsoft Entra ID tokens and build certificate client assertions.
// Node 18+, one dependency: npm install jose
import { createHash, randomUUID, X509Certificate } from "node:crypto";
import { createRemoteJWKSet, importPKCS8, jwtVerify, SignJWT } from "jose";

const LOGIN = "https://login.microsoftonline.com";

/**
 * Returns verify(idToken, expectedNonce) for your app registration.
 * Single-tenant: pass tenantId. Multi-tenant: pass allowedTenant, an async (tid) => boolean
 * that looks the tenant up in your own list of onboarded customers.
 */
export function entraTokenVerifier({ clientId, tenantId, allowedTenant, keys }) {
  if (!clientId || Boolean(tenantId) === Boolean(allowedTenant)) {
    throw new Error("pass clientId and exactly one of tenantId or allowedTenant");
  }
  const pinned = tenantId?.toLowerCase();
  const jwks = keys ?? createRemoteJWKSet(new URL(`${LOGIN}/${pinned ?? "organizations"}/discovery/v2.0/keys`));

  return async function verify(idToken, expectedNonce) {
    if (typeof expectedNonce !== "string" || expectedNonce === "") {
      throw new Error("expectedNonce is required: pass the nonce you stored when sign-in started");
    }
    const { payload } = await jwtVerify(idToken, jwks, {
      algorithms: ["RS256"], // Entra signs ID tokens with RS256; refuse anything else
      audience: clientId,
      requiredClaims: ["exp"], // exp is only checked when present, so require it
      clockTolerance: 60, // seconds of clock skew
    });
    const { tid, oid } = payload;
    if (typeof tid !== "string" || typeof oid !== "string") throw new Error("token has no tid or oid");

    // The token's issuer must be the v2.0 issuer for the tenant named inside the token itself.
    if (payload.iss !== `${LOGIN}/${tid}/v2.0`) throw new Error("issuer does not match the token's tenant");
    // ...and that tenant must be one you accept.
    if (pinned ? tid.toLowerCase() !== pinned : !(await allowedTenant(tid.toLowerCase()))) {
      throw new Error("tenant is not allowed to sign in");
    }
    if (payload.nonce !== expectedNonce) throw new Error("nonce does not match this sign-in");

    // Identify the person by tid + oid. name and preferred_username are for display only.
    return {
      tid: tid.toLowerCase(),
      oid: oid.toLowerCase(),
      name: typeof payload.name === "string" ? payload.name : "",
      username: typeof payload.preferred_username === "string" ? payload.preferred_username : "",
    };
  };
}

/**
 * Builds the client_assertion JWT for the token request, signed with your app's certificate.
 * tokenEndpoint is the exact URL you'll POST the token request to. certPem is the certificate you
 * uploaded to the app registration; keyPem is its PKCS#8 private key.
 */
export async function clientAssertion({ clientId, tokenEndpoint, certPem, keyPem }) {
  const thumbprint = createHash("sha256").update(new X509Certificate(certPem).raw).digest("base64url");
  const now = Math.floor(Date.now() / 1000);
  return new SignJWT({})
    .setProtectedHeader({ alg: "PS256", typ: "JWT", "x5t#S256": thumbprint })
    .setAudience(tokenEndpoint)
    .setIssuer(clientId)
    .setSubject(clientId)
    .setJti(randomUUID())
    .setNotBefore(now)
    .setIssuedAt(now)
    .setExpirationTime(now + 300) // five minutes
    .sign(await importPKCS8(keyPem, "PS256"));
}

Using it

For a single-tenant app, pin your tenant ID:

const verify = entraTokenVerifier({ clientId: process.env.ENTRA_CLIENT_ID, tenantId: process.env.ENTRA_TENANT_ID });

For a multi-tenant app, pass a function that looks the tenant up in your own customer records. Return true only for organizations you've onboarded:

const verify = entraTokenVerifier({
  clientId: process.env.ENTRA_CLIENT_ID,
  allowedTenant: async (tid) => (await db.customers.findByTenantId(tid))?.status === "active",
});

Then in your callback, after checking state and redeeming the code:

const { tid, oid, name, username } = await verify(tokenResponse.id_token, stored.nonce);

Any failure throws. Treat every error as "sign-in failed", and log the error's code rather than the token.

A few details are deliberate:

  • The nonce is required. If you forget to pass it, the function throws. Otherwise a token with no nonce would match an expected nonce of undefined, and the replay check would silently switch off.
  • exp is a required claim. JWT libraries generally only validate expiry when the claim is present, so a signed token without one would never expire.
  • Identifiers are returned in lower case, so the same person always maps to the same database key.
  • The algorithm is pinned to RS256, the algorithm Entra uses for ID tokens.

We tested it against 37 cases, locally minted tokens covering every check: the wrong audience, expired and not-yet-valid tokens, a token from another tenant, an issuer that doesn't match the token's tid, the {tenantid} template left unfilled, the older v1.0 issuer format, a personal Microsoft account, missing or non-string oid and tid, nonce mismatches, a different signing key reusing a known kid, a tampered payload, unsigned and HMAC-signed tokens, and a correctly signed token using a different algorithm. Each rejection test also checks the reason for the rejection, not just that one happened. The assertion tests compare the thumbprint against OpenSSL's SHA-256 fingerprint of the same certificate, and verify the signature with the certificate's public key.

We also checked the tests cover every safeguard: weakening any of 27 details in the module, such as removing the audience check or widening the clock allowance to an hour, makes the tests fail. Finally, we pointed it at Microsoft's published signing keys, to confirm it fetches them and selects the right key.

Mistakes we've learned to avoid

  • Treating any valid token as a customer. On a multi-tenant registration, the signature and issuer checks pass for every organization. The tenant check is what makes it your customers.
  • Keying accounts on email address. Addresses change and get reused. Store tid and oid.
  • Trusting the admin consent redirect. Bind tenants from verified tokens, as described above.
  • Logging too much. Never log ID tokens, authorization codes or client assertions. Be careful with HTTP client error objects too: many include the original request, and the token request body carries your client credential. Log the HTTP status and the error code Microsoft returns.
  • Echoing error parameters into logs unfiltered. The error and error_description values on your callback URL come from the browser, so anyone can put anything in them. Strip them to plain characters and a sensible length before logging.
  • Putting the staff console on the same sign-in path as customers. Ours has its own hostname and redirect URI, restricted to our own networks. We also keep an emergency way in that doesn't depend on Microsoft sign-in, with its own multi-factor authentication, and every use of it is logged.

Error codes you'll meet while building it

Microsoft returns an AADSTS code with each sign-in error. These are the ones we've found most useful to recognize.

CodeMeaningUsual cause in this flow
AADSTS50011The reply address doesn't match the ones configured for the appThe redirect_uri differs from the registered one, often by a trailing slash, http vs https, or a port
AADSTS50194The app isn't configured as multi-tenant, so the /common endpoint isn't supportedA single-tenant registration using common or organizations: use your tenant's endpoint, or change the supported account types
AADSTS700016The application wasn't found in the directory or tenantThe wrong client ID, or a request sent to the wrong tenant
AADSTS50020The user account doesn't exist in the tenantSomeone from another organization, or a personal account, signing in to a single-tenant app
AADSTS65001 / AADSTS90094Consent hasn't been granted, or administrator consent is requiredThe customer's administrator hasn't approved the app yet: send them the admin consent link
AADSTS65004The user declined to consentThe person chose not to approve the app. They can retry, or their administrator can consent for the organization
AADSTS53003Access has been blocked by Conditional Access policiesThe customer's own sign-in policy applied. Their administrator can see which policy in the Entra sign-in logs
AADSTS501481The code verifier doesn't match the code challengeThe stored verifier belongs to a different sign-in attempt, or the challenge was encoded as padded base64 instead of base64url
AADSTS54005The authorization code was already redeemedThe callback ran twice for one code, for example after a page refresh. Start a new sign-in
AADSTS700027The client assertion failed signature validationThe certificate isn't uploaded to this app registration, the private key doesn't match it, or the thumbprint header is wrong
AADSTS7000215An invalid client secret was providedCommonly the secret's ID was copied instead of its value
AADSTS7000222The client secret has expiredCreate a new credential, and consider moving to a certificate

How it works in our customer portal

In the intSignal portal, a customer's Microsoft administrator approves the portal for their organization once, using a consent link we generate for that customer. After that, their portal administrator invites people, and those people sign in with their normal Microsoft 365 account, including whatever multi-factor authentication and Conditional Access policies their organization requires. The portal never sees a password, and roles are managed in the portal rather than in Microsoft 365. When an administrator disables someone or changes their roles, it applies on that person's next action.

The customer-facing steps are in Signing in & single sign-on. For the broader identity picture, see least-privilege IAM in the cloud and API security and the OWASP API Top 10. If you'd like help designing sign-in and access for your own environment, that's part of our identity and access management service.

Frequently asked questions

Should a multi-tenant app use the common or organizations endpoint?

Use organizations if you only serve work and school accounts, which is true of most business applications. common also accepts personal Microsoft accounts, which then need to be rejected by your tenant check.

Do I need PKCE if my app has a client secret or certificate?

It's recommended. RFC 9700, the IETF's OAuth security best practice, recommends PKCE for confidential clients because it protects against misuse and injection of authorization codes. Use it together with the state and nonce checks.

Not as the lasting identifier. Microsoft's documentation says the email claim should never be used for authorization. An email address can reasonably find a pending invitation inside a tenant you've already linked, but once the invitation is redeemed, identify the person by tid and oid.

Why does the multi-tenant metadata show {tenantid} in the issuer?

Because the organizations and common endpoints serve every tenant, there's no single issuer to publish. Each token's issuer contains the tenant that signed the person in, so your app fills the template with the token's tid and compares the result with the token's iss.

Does disabling a user in Microsoft Entra ID sign them out of my app?

It stops them signing in again, but your application's session is separate. Check the user's status and roles on each request, and keep sessions reasonably short, so access you remove in your app ends immediately.

Share this article