Fix for st.login() Microsoft "invalid_claim: iss" on shared multi-tenant Entra app (no MSAL rewrite needed)

Posting this because every thread I found on this bug (#11391, authlib/authlib#605, this forum) ends at “use /consumers only” or “drop st.login(), write your own MSAL flow.” There’s a smaller fix that keeps st.login() as-is.

Setup this applies to: one Azure app registration, shared across many separate app deployments (one per customer/tenant of your product — own domain, own redirect URI, own downstream authorization) — the same pattern as a shared Google OAuth client added to multiple sites. To accept sign-in from any customer’s Microsoft organization, that one Entra app has to be registered as Entra-multi-tenant (AzureADMultipleOrgs), pointed at Microsoft’s /common (or /organizations) endpoint.

The bug

Do that, and every work/school login fails with:

authlib.jose.errors.InvalidClaimError: invalid_claim: Invalid claim 'iss'

Root cause: /common’s discovery document returns issuer literally templated:

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

{tenantid} is never expanded by Microsoft — the client is supposed to substitute it. Authlib doesn’t. It builds claims_options straight from the raw metadata field (authlib/integrations/base_client/sync_openid.py, and identically in async_openid.py for the Starlette backend):

if claims_options is None and "issuer" in metadata:
    claims_options = {"iss": {"values": [metadata["issuer"]]}}

…then compares that literal string against the real per-user issuer on every returned ID token (https://login.microsoftonline.com/``<real-tenant-guid>/v2.0). Never matches. Only /consumers (personal accounts) avoids it, because that endpoint’s issuer isn’t templated.

st.login() has no config surface that reaches claims_options — Streamlit’s own callback route calls authorize_access_token() with no kwargs — so there’s no secrets.toml-level fix.

The fix

Authlib’s own claim validator already supports a validate callable as an alternative to exact-match values — a documented extension point, not a private hack (authlib/jose/rfc7519/claims.py::BaseClaims._validate_claim_value). Monkeypatch OpenIDMixin.parse_id_token so that only when the discovered issuer still contains the literal {tenantid} placeholder, the iss check swaps from string-equality to a regex against the real Microsoft tenant-GUID shape:

import re

_TENANT_ISSUER_RE = re.compile(
    r"^https://login\.microsoftonline\.com/"
    r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
    r"/v2\.0$"
)

def _validate_templated_tenant_issuer(claims, value):
    return bool(value) and bool(_TENANT_ISSUER_RE.match(str(value)))

wrapped around parse_id_token, swapping in {"iss": {"validate": _validate_templated_tenant_issuer}} whenever metadata["issuer"] contains {tenantid}. Google and single-tenant Microsoft configs are untouched — the wrapper is a no-op unless it sees the literal placeholder.

Two things worth calling out if you do this:

  • Patch both classes. Streamlit has two server backends (Tornado, Starlette), each with its own OpenIDMixin/AsyncOpenIDMixin, and both carry the identical bug. Patching only the one your app currently uses will silently break — with this exact same error — if the backend ever changes.
  • Anchor the regex fully. re.search() or an unanchored pattern lets a crafted issuer like .../<valid-guid>.evil.com/v2.0 slip through by containing a real GUID as a substring.

Result: one shared multi-tenant Entra app registration, one redirect URI added per deployment. Whatever authorization check runs downstream of login (email-domain allowlist, etc.) stays the real gate — this patch only fixes issuer validation, nothing else.

Upstream refs, both still open as of writing: