Entra ID Bug — Case-Sensitivity Mismatch in External Tenant Identities

Entra ID Bug — Case-Sensitivity Mismatch in External Tenant Identities

Another Entra ID bug, this time in External Tenants (the CIAM flavor). What makes this one awkward is that the only supported path to my business requirements runs straight through the bug. If Microsoft fixes the bug in isolation, the setup stops working and there's nothing to replace it with.

The setup

I'm running an External Tenant with these requirements:

  • Email OTP only — every user authenticates with a one-time code to their email, regardless of whether their domain belongs to another Entra tenant.
  • Home Realm Discovery disabled — nobody gets forwarded to their home tenant for auth.
  • Invitation-only — no self-registration, no self-service. Users get created via POST /invitations through a custom onboarding workflow.
  • Custom registration flow — invited users land on a branded page built on top of an authorization URL like:
https://{tenant}.ciamlogin.com/{tenant-id}/oauth2/v2.0/authorize
  ?client_id={app-id}
  &response_type=code
  &scope=openid
  &domain_hint=otp
  &[email protected]
  &redirect_uri=https://{redirect}

Problem 1: HRD overrides the OTP flow

If an invited user's email domain is associated with an existing Entra ID tenant, HRD kicks in and forwards them to their home tenant for auth. domain_hint=otp is ignored. There's no HomeRealmDiscoveryPolicy resource in External Tenants — that one only exists in workforce tenants — and no per-user or per-app override.

So domain_hint is essentially decorative for any user whose domain is federated.

Problem 2: the HRD workaround breaks invitation state

The only way to bypass HRD is to manually patch the user's identities collection and give them a mail issuer identity:

PATCH https://graph.microsoft.com/v1.0/users/{USER-OBJECT-ID}
Content-Type: application/json

{
  "identities": [
    {
      "signInType": "federated",
      "issuer": "mail",
      "issuerAssignedId": "[email protected]"
    },
    {
      "signInType": "userPrincipalName",
      "issuer": "{tenant}.onmicrosoft.com",
      "issuerAssignedId": "{prefix}#EXT#@{tenant}.onmicrosoft.com"
    }
  ]
}

After this patch, Email OTP works and HRD is bypassed. But the invitation workflow doesn't see this sign-in as a redemption — externalUserState stays stuck on "PendingAcceptance" indefinitely. The attribute is read-only, and there's no Graph API operation to transition it manually.

So the user can sign in, but their invitation state says they never accepted. Anything downstream that keys off externalUserState is broken.

The bug

The identities.issuerAssignedId property does case-sensitive string comparison at the identity record layer. The authentication flow does case-insensitive comparison when resolving the user from the email at sign-in. The two layers disagree.

A stored identity of [email protected] and a sign-in attempt with [email protected]:

  • At the auth layer — same user. OTP goes to the right mailbox, sign-in succeeds.
  • At the identity layer — different strings. The system creates a new identity entry on the user with whatever casing the user typed in.

That new identity record looks enough like a fresh provisioning event that the invitation workflow fires and transitions externalUserState to "Accepted". Which happens to be exactly what I need.

Reproducing it

  1. Invite the user via POST /invitations. Object is created with externalUserState: "PendingAcceptance".
  2. Patch their identities with a mail issuer — but deliberately miscase the email:
PATCH https://graph.microsoft.com/v1.0/users/{USER-OBJECT-ID}

{
  "identities": [
    {
      "signInType": "federated",
      "issuer": "mail",
      "issuerAssignedId": "[email protected]"
    },
    {
      "signInType": "userPrincipalName",
      "issuer": "{tenant}.onmicrosoft.com",
      "issuerAssignedId": "{prefix}#EXT#@{tenant}.onmicrosoft.com"
    }
  ]
}
  1. User clicks the registration link and signs in as [email protected]. Case-insensitive match at the auth layer — OTP delivered, sign-in succeeds. Case-sensitive mismatch at the identity layer — new identity record created. Invitation workflow sees the new record and transitions state to "Accepted".
  2. Clean up the miscased entry via a follow-up PATCH.

The user ends up with a single correctly-cased identity, signed in via Email OTP, HRD bypassed, invitation marked accepted. Every requirement met, entirely by exploiting the bug.

Why the inconsistency matters

Per RFC 5321 §2.4, the local part of an email address is technically case-sensitive and the domain is case-insensitive. In practice, basically every mail provider treats the whole address as case-insensitive, which is what the auth layer does here. Either behavior is defensible. What isn't defensible is the two layers disagreeing.

In External Tenants specifically, the consequences bleed into real workflows:

  • Duplicate identity records for any user who types their email with different casing than what's stored. This isn't limited to my workaround — it affects any user in the tenant.
  • Non-deterministic invitation state transitions — whether externalUserState flips depends on whether the user's capitalization happened to mismatch the record. That's not an auditable workflow event.
  • Audit trail ambiguity — identity records created through case-mismatch auth are indistinguishable in logs from legitimate provisioning.

The uncomfortable part

I reported this to Microsoft as a bug, but the fix can't be to normalize casing in isolation. If they patch the case-sensitivity without addressing the two underlying product gaps — no HRD override mechanism, and no way to transition invitation state for patched identities — then there is no supported or unsupported path to invitation-only, Email OTP-enforced CIAM for users with federated email domains. My production workaround stops working and there's no replacement.

What should actually ship:

  1. Acknowledge the case-sensitivity mismatch as a bug, but don't ship that fix alone.
  2. Expose an HRD override in External Tenants — a HomeRealmDiscoveryPolicy equivalent, or a tenant-level "force Email OTP" switch.
  3. Either recognize successful OTP auth through a patched mail identity as a valid redemption, or provide a Graph operation to set externalUserState directly.

For now, the workaround above is in production. If you're stuck on the same requirements, the miscased-PATCH dance is what's working.