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.0slip 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:
- Cannot use common tenant with microsoft login: authlib.jose.errors.InvalidClaimError: invalid_claim: Invalid claim 'iss' · Issue #11391 · streamlit/streamlit · GitHub
- InvalidClaimError "iss" because "options" inconsistent with "option_values" when using Azure's OAuth (templated endpoint) · Issue #605 · authlib/authlib · GitHub