Multiple ext-jwt-signers with overlapping JWKS kids cause intermittent primary auth failures (GetIssuerByKid collision)

Ziti version: v2.0.0

Summary

When two ext-jwt-signers are configured whose JWKS endpoints expose overlapping key IDs (kid), primary external-JWT authentication fails intermittently for valid tokens. The same token and identity succeed on some attempts and fail on others, with no configuration changes in between. The problem only appears once a second signer exists; with a single signer it never occurs.

This is easily hit in practice because multiple Azure AD / Entra ID tenants share Microsoft's signing-key pool, so their per-tenant JWKS (/discovery/v2.0/keys) return the same kids. In my case the two tenants' JWKS overlapped on every key the active tenant uses.

Environment / setup to reproduce

  • Two ext-jwt-signers, each configured for a different OIDC issuer (e.g. two Entra tenants), each with jwksEndpoint set and kid empty.
  • Their JWKS share one or more kids.
  • Each signer has its own auth policy (primary.extJwt.allowedSigners pinned to the respective signer).
  • Note: the second signer can even be enabled=false and still trigger the failure.

Expected behavior

A presented JWT is validated against the signer matching its iss (and aud) claim. A second, unrelated (or disabled) signer that merely happens to share a kid should not affect validation.

Actual behavior

Intermittently, authentication fails with:

level=error
file=controller/model/authenticator_mod_ext_jwt.go:182
func=(*AuthModuleExtJwt).ProcessPrimary
msg="encountered 1 candidate JWTs, none of which were valid for primary authentication"

The failure rate scales with how often the signer cache is rebuilt; the same token alternates between success and this error.

Root-cause analysis

Bearer-token → signer resolution appears to go through a single-valued kid → issuer lookup:

// common/security_tokens.go
GetIssuerByKid(kid string) TokenIssuer // "returns the TokenIssuer that owns the given key ID"

A kid can only map to one TokenIssuer. When two signers expose the same kid, that key is attributed to a single signer. The chosen BearerTokenHeader.TokenIssuer is therefore frequently the wrong signer for the token actually presented. Then in ProcessPrimary (authenticator_mod_ext_jwt.go):

  • candidates are filtered by TokenIssuer != nil && TokenIssuer.IsEnabled() (~line 173). If the colliding kid resolved to a disabled signer, the candidate list becomes empty → the :182 "none valid" error, even though a correctly-configured enabled signer for the token's real iss exists.
  • If the colliding signer is enabled instead, verifyTokenClaims later rejects on token issuer [...] does not match expected tch (~lines 450–463), i.e. it never falls back to trying the other signer that shares the kid.

The intermittency comes from the kid → issuer map being (re)built repeatedly (every UpdateEntityCommand[ExternalJwtSigner]);d flips between the two signers across rebuilds, so identical requests alternate between success and failure.

Two consequences worth highlighting:

  1. A disabled signer still poisons resolution — it still owns kids in the lookup, so disabling the unwanted signer does not help; only deleting it does.
  2. There is no way to run two signers for two issuers that share a key pool (e.g. two Entra tenants) reliably.

Suggested fix

Resolve the signer by the token's iss claim rather than (or in addition to) kid — the codebase already exposes GetByIssuerStyInspection(token) which iterate issuers until one verifies. Alternatively, allow a kid to map to multiple issuers anddisambiguate the candidate set by iss/aud before the enabled-filter, and exclude disabled signers from the kid index entirely.

Hey, thank you for the report. This is something we discovered internally a few weeks back.

I did a quick check to see if there was a logged issue to link here, but I didn't find it immediately. I'll either find it or create one.

Great thanks

this issue has taken some time to discover

I just wanted to ask if this has been fixed in the latest 2.1 pre-release?

Hi @msbusk, I think this got overlooked, thanks for bumping it. I've create a tracking issue for it: Overlapping JWKS kids across ext-jwt-signers cause intermittent primary auth failures (GetIssuerByKid collision) · Issue #4118 · openziti/ziti · GitHub and I'll work on getting the fix in for 2.1 and backported to 2.0.

Thank you,
Paul

I have some pre-investigation I did that I didn't finish before I left on vacation.

This touches a sensitive area and may have some ripple that may affect other features. I will be back on Monday to review any work.