CVE-2026-49998: Centrifugo Cross-Issuer JWT Authentication Bypass via JWKS Kid Cache Collision
In Centrifugo's multi-tenant dynamic JWKS setup, a valid token for one tenant can silently authenticate as a completely different tenant because the key cache is keyed only by kid, not by which JWKS e
The problem
Centrifugo supports dynamic JWKS endpoint templates such as `https://idp.example.com/{{tenant}}/jwks.json`, where `{{tenant}}` is extracted from the JWT `iss` or `aud` claim. This powers multi-tenant deployments where each tenant has its own signing keys.
The JWKS key cache (`internal/jwks/cache_ttl.go`) and the `singleflight` deduplication group (`internal/jwks/manager.go`) both key lookups solely on the JWT header `kid`. The resolved JWKS URL is computed only after the cache is consulted. As a result, once tenant A's key is stored under `kid=shared-kid`, any subsequent token claiming tenant B with the same `kid` is verified against tenant A's cached public key, without ever contacting tenant B's JWKS endpoint.
Both connection tokens (`VerifyConnectToken`) and subscription tokens (`VerifySubscribeToken`) share the same vulnerable manager.
Proof of concept
A working proof-of-concept for CVE-2026-49998 in github.com/centrifugal/centrifugo/v6, with the exact payload below.
// Scenario: two tenants, same kid value, attacker holds tenant-A's private key.
// Config (centrifugo):
// client.token.jwks_public_endpoint: "https://idp.example.com/{{tenant}}/jwks.json"
// client.token.issuer_regex: "^(?P<tenant>tenant-a|tenant-b)$"
// Step 1 – authenticate legitimately as tenant-a (primes cache with tenant-A's public key under kid="shared-kid")
legitTenantAToken := buildJWT(user="tenant-a-user", iss="tenant-a", kid="shared-kid", signWith=tenantAPrivateKey)
POST /connection Authorization: Bearer <legitTenantAToken> // -> 200 OK, cache now: {"shared-kid": tenantA_pubkey}
// Step 2 – forge a tenant-b token signed with tenant-A's private key
forgedTenantBToken := buildJWT(user="victim", iss="tenant-b", kid="shared-kid", signWith=tenantAPrivateKey)
POST /connection Authorization: Bearer <forgedTenantBToken>
// -> 200 OK (tenant-B JWKS endpoint is NEVER contacted)
// -> authenticated as "victim" inside tenant-b's namespace
// Minimal Go unit test (from advisory PoC – run against <= 6.8.0):
// go test ./internal/jwtverify -run TestJWKSCacheKeyIsNotScopedToTemplatedEndpointPoC -count=1 -v
// Expected on vulnerable build: PASS (forged token accepted, tenant-B request counter unchanged)The root cause (CWE-347: Improper Verification of Cryptographic Signature) is that `Manager.FetchKey` in `internal/jwks/manager.go` calls `m.cache.Get(kid)` and `m.group.Do(kid, ...)` before computing `jwkURL := m.url.ExecuteString(tokenVars)`. The cache key is only `kid`, so a key fetched from tenant A's endpoint satisfies a lookup intended for tenant B's endpoint if both publish a key with the same `kid`.
The fix in PR #1142 (v6.8.1) resolves the JWKS URL first, then forms a composite cache key of `resolvedURL + "\x00" + kid` for both the TTL cache and the `singleflight` group. This ensures keys from different trust domains never collide, even when issuers reuse common `kid` labels like `current`, `default`, or rotation counters.
The `kid` field is not globally unique by the JWK specification, so relying on it alone as a namespace boundary is always unsafe in a multi-issuer context.
The fix
Upgrade to Centrifugo v6.8.1. The patch (PR #1142) scopes every cache and singleflight lookup to the composite key `resolvedJWKSEndpointURL + "\x00" + kid`, eliminating the cross-tenant key reuse. No configuration changes are required for the cache-scoping fix itself, though you should confirm your `issuer_regex` / `audience_regex` named groups enumerate only explicit allowed tenant values (e.g. `^(?P<tenant>tenant-a|tenant-b)$`) as a defence-in-depth measure.
Reported by sondt99.
Related research
- high · 7.5CVE-2026-39829CVE-2026-39829: golang.org/x/crypto/ssh RSA/DSA Key Size DoS
- high · 7.1CVE-2026-50163CVE-2026-50163: oras-go Hardlink Path Traversal via CWD Resolution
- high · 8.1CVE-2026-50138CVE-2026-50138: goshs WebDAV Mode-Flag Access Control Bypass
- high · 7.5CVE-2026-50151CVE-2026-50151: oras-go Credential Leak via Unvalidated Location Header in Blob Upload