CVE-2020-26892
Description
The JWT library in NATS nats-server before 2.1.9 has Incorrect Access Control because of how expired credentials are handled.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
NATS nats-server before 2.1.9 incorrectly handles expired JWT credentials, allowing unauthorized access after revocation.
Vulnerability
Description The NATS JWT library, used by nats-server before version 2.1.9, contains an incorrect access control flaw in how expired credentials are handled. Specifically, the server's revocation checks for user accounts and activation tokens were comparing revocation timestamps against the current system time (time.Now().Unix()) rather than the token's issuedAt time [3]. This meant that if a token was revoked, the revocation would only become effective after the current system time passed the revocation timestamp, but the token's expiration was not properly validated against the revocation. The fix introduced a new isRevoked function that correctly compares revocation timestamps to the token's issuedAt field [3].
Exploitation
Conditions This vulnerability can be exploited by any user possessing a previously valid JWT credential that has been revoked or whose expiration period has passed. The attack does not require authentication to the NATS server beyond presenting the expired or revoked JWT. The attacker must send a connection request using the stale token; due to the flawed logic, the server would accept it as valid because the revocation check was not properly tied to the token's issuance time [3]. The attack can be carried out remotely over the network.
Impact
Successful exploitation allows an attacker to gain unauthorized access to the NATS messaging system using credentials that should no longer be valid. The attacker could then publish or subscribe to subjects according to the permissions originally granted by the revoked or expired token, potentially leading to disclosure of sensitive messages or disruption of message flows. This is a direct violation of the access control intended by JWT revocation mechanisms.
Mitigation
NATS nats-server version 2.1.9, released 2020-11-02, contains the fix for this vulnerability [1][2][4]. Users must upgrade to this version or later to ensure that expired and revoked credentials are properly rejected. There are no known workarounds that address the root cause. The fix also covers the related nil dereference issue (CVE-2020-26521) that could cause a denial of service [4].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/nats-io/jwtGo | < 1.1.0 | 1.1.0 |
Affected products
6- NATS/nats-serverdescription
- osv-coords5 versionspkg:apk/chainguard/nats-serverpkg:apk/chainguard/nats-server-compatpkg:apk/wolfi/nats-serverpkg:apk/wolfi/nats-server-compatpkg:golang/github.com/nats-io/jwt
< 0+ 4 more
- (no CPE)range: < 0
- (no CPE)range: < 0
- (no CPE)range: < 0
- (no CPE)range: < 0
- (no CPE)range: < 1.1.0
Patches
21e08b67f08e1[FIXED] User and claims activation revocation checks
2 files changed · +25 −26
server/accounts.go+24 −25 modified@@ -1525,6 +1525,16 @@ func (a *Account) activationExpired(exportAcc *Account, subject string, kind jwt } } +func isRevoked(revocations map[string]int64, subject string, issuedAt int64) bool { + if revocations == nil { + return false + } + if t, ok := revocations[subject]; !ok || t < issuedAt { + return false + } + return true +} + // checkActivation will check the activation token for validity. func (a *Account) checkActivation(importAcc *Account, claim *jwt.Import, expTimer bool) bool { if claim == nil || claim.Token == "" { @@ -1567,13 +1577,7 @@ func (a *Account) checkActivation(importAcc *Account, claim *jwt.Import, expTime } } // Check for token revocation.. - if a.actsRevoked != nil { - if t, ok := a.actsRevoked[act.Subject]; ok && t <= time.Now().Unix() { - return false - } - } - - return true + return !isRevoked(a.actsRevoked, act.Subject, act.IssuedAt) } // Returns true if the activation claim is trusted. That is the issuer matches @@ -1710,16 +1714,10 @@ func (a *Account) clearExpirationTimer() bool { } // checkUserRevoked will check if a user has been revoked. -func (a *Account) checkUserRevoked(nkey string) bool { +func (a *Account) checkUserRevoked(nkey string, issuedAt int64) bool { a.mu.RLock() defer a.mu.RUnlock() - if a.usersRevoked == nil { - return false - } - if t, ok := a.usersRevoked[nkey]; !ok || t > time.Now().Unix() { - return false - } - return true + return isRevoked(a.usersRevoked, nkey, issuedAt) } // Check expiration and set the proper state as needed. @@ -2010,6 +2008,8 @@ func (s *Server) updateAccountClaimsWithRefresh(a *Account, ac *jwt.AccountClaim for pk, t := range ac.Revocations { a.usersRevoked[pk] = t } + } else { + a.usersRevoked = nil } a.incomplete = len(incompleteImports) != 0 for _, i := range incompleteImports { @@ -2024,7 +2024,7 @@ func (s *Server) updateAccountClaimsWithRefresh(a *Account, ac *jwt.AccountClaim return clients[i].start.After(clients[j].start) }) } - now := time.Now().Unix() + for i, c := range clients { a.mu.RLock() exceeded := a.mconns != jwt.NoLimit && i >= int(a.mconns) @@ -2035,17 +2035,16 @@ func (s *Server) updateAccountClaimsWithRefresh(a *Account, ac *jwt.AccountClaim } c.mu.Lock() c.applyAccountLimits() - // Check for being revoked here. We use ac one to avoid - // the account lock. - var nkey string - if c.user != nil { - nkey = c.user.Nkey - } + theJWT := c.opts.JWT c.mu.Unlock() - // Check if we have been revoked. - if ac.Revocations != nil { - if t, ok := ac.Revocations[nkey]; ok && now >= t { + // Check for being revoked here. We use ac one to avoid the account lock. + if ac.Revocations != nil && theJWT != "" { + if juc, err := jwt.DecodeUserClaims(theJWT); err != nil { + c.Debugf("User JWT not valid: %v", err) + c.authViolation() + continue + } else if ok := ac.IsClaimRevoked(juc); ok { c.sendErrAndDebug("User Authentication Revoked") c.closeConnection(Revocation) continue
server/auth.go+1 −1 modified@@ -474,7 +474,7 @@ func (s *Server) processClientOrLeafAuthentication(c *client) bool { c.Debugf("Signature not verified") return false } - if acc.checkUserRevoked(juc.Subject) { + if acc.checkUserRevoked(juc.Subject, juc.IssuedAt) { c.Debugf("User authentication revoked") return false }
e11ce317263cFixed documentation issue for jwt activation revocation (#106)
3 files changed · +40 −23
exports.go+6 −6 modified@@ -151,16 +151,16 @@ func (e *Export) ClearRevocation(pubKey string) { e.Revocations.ClearRevocation(pubKey) } -// IsRevokedAt checks if the public key is in the revoked list with a timestamp later than -// the one passed in. Generally this method is called with time.Now() but other time's can -// be used for testing. +// IsRevokedAt checks if the public key is in the revoked list with a timestamp later than the one passed in. +// Generally this method is called with the subject and issue time of the jwt to be tested. +// DO NOT pass time.Now(), it will not produce a stable/expected response. func (e *Export) IsRevokedAt(pubKey string, timestamp time.Time) bool { return e.Revocations.IsRevoked(pubKey, timestamp) } -// IsRevoked checks if the public key is in the revoked list with time.Now() -func (e *Export) IsRevoked(pubKey string) bool { - return e.Revocations.IsRevoked(pubKey, time.Now()) +// IsRevoked does not perform a valid check. Use IsRevokedAt instead. +func (e *Export) IsRevoked(_ string) bool { + return true } // Exports is a slice of exports
v2/exports.go+12 −8 modified@@ -175,16 +175,20 @@ func (e *Export) ClearRevocation(pubKey string) { e.Revocations.ClearRevocation(pubKey) } -// IsRevokedAt checks if the public key is in the revoked list with a timestamp later than -// the one passed in. Generally this method is called with time.Now() but other time's can -// be used for testing. -func (e *Export) IsRevokedAt(pubKey string, timestamp time.Time) bool { - return e.Revocations.IsRevoked(pubKey, timestamp) +// isRevoked checks if the public key is in the revoked list with a timestamp later than the one passed in. +// Generally this method is called with the subject and issue time of the jwt to be tested. +// DO NOT pass time.Now(), it will not produce a stable/expected response. +func (e *Export) isRevoked(pubKey string, claimIssuedAt time.Time) bool { + return e.Revocations.IsRevoked(pubKey, claimIssuedAt) } -// IsRevoked checks if the public key is in the revoked list with time.Now() -func (e *Export) IsRevoked(pubKey string) bool { - return e.Revocations.IsRevoked(pubKey, time.Now()) +// IsClaimRevoked checks if the activation revoked the claim passed in. +// Invalid claims (nil, no Subject or IssuedAt) will return true. +func (e *Export) IsClaimRevoked(claim *ActivationClaims) bool { + if claim == nil || claim.IssuedAt == 0 || claim.Subject == "" { + return true + } + return e.isRevoked(claim.Subject, time.Unix(claim.IssuedAt, 0)) } // Exports is a slice of exports
v2/exports_test.go+22 −9 modified@@ -187,48 +187,61 @@ func TestExportRevocation(t *testing.T) { account.Exports.Add(e) - pubKey := "bar" + ikp := createAccountNKey(t) + pubKey := publicKey(ikp, t) + + ac := NewActivationClaims(pubKey) + ac.IssuerAccount = apk + ac.Name = "foo" + ac.Activation.ImportSubject = "foo" + ac.Activation.ImportType = Stream + aJwt, _ := ac.Encode(akp) + ac, err := DecodeActivationClaims(aJwt) + if err != nil { + t.Errorf("Failed to decode activation claim: %v", err) + } + now := time.Now() // test that clear is safe before we add any e.ClearRevocation(pubKey) - if e.IsRevokedAt(pubKey, now) { + if e.isRevoked(pubKey, now) { t.Errorf("no revocation was added so is revoked should be false") } e.RevokeAt(pubKey, now.Add(time.Second*100)) - if !e.IsRevokedAt(pubKey, now) { + if !e.isRevoked(pubKey, now) { t.Errorf("revocation should hold when timestamp is in the future") } - if e.IsRevokedAt(pubKey, now.Add(time.Second*150)) { + if e.isRevoked(pubKey, now.Add(time.Second*150)) { t.Errorf("revocation should time out") } e.RevokeAt(pubKey, now.Add(time.Second*50)) // shouldn't change the revocation, you can't move it in - if !e.IsRevokedAt(pubKey, now.Add(time.Second*60)) { + if !e.isRevoked(pubKey, now.Add(time.Second*60)) { t.Errorf("revocation should hold, 100 > 50") } encoded, _ := account.Encode(akp) decoded, _ := DecodeAccountClaims(encoded) - if !decoded.Exports[0].IsRevokedAt(pubKey, now.Add(time.Second*60)) { + if !decoded.Exports[0].isRevoked(pubKey, now.Add(time.Second*60)) { t.Errorf("revocation should last across encoding") } e.ClearRevocation(pubKey) - if e.IsRevokedAt(pubKey, now) { + if e.IsClaimRevoked(ac) { t.Errorf("revocations should be cleared") } - e.RevokeAt(pubKey, now.Add(time.Second*1000)) + e.RevokeAt(pubKey, now) - if !e.IsRevoked(pubKey) { + if !e.IsClaimRevoked(ac) { t.Errorf("revocation be true we revoked in the future") } }
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
11- github.com/advisories/GHSA-4w5x-x539-ppf5ghsaADVISORY
- lists.fedoraproject.org/archives/list/package-announce%40lists.fedoraproject.org/message/VT67XCLIIBYRT762SVFBYFFTQFVSM3SI/mitrevendor-advisoryx_refsource_FEDORA
- nvd.nist.gov/vuln/detail/CVE-2020-26892ghsaADVISORY
- advisories.nats.io/CVE/CVE-2020-26892.txtghsaWEB
- github.com/nats-io/jwt/commit/e11ce317263cef69619fc1ca743b195d02aa1d8aghsaWEB
- github.com/nats-io/jwt/security/advisories/GHSA-4w5x-x539-ppf5ghsaWEB
- github.com/nats-io/nats-server/commit/1e08b67f08e18cd844dce833a265aaa72500a12fghsaWEB
- github.com/nats-io/nats-server/commits/masterghsax_refsource_MISCWEB
- lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/VT67XCLIIBYRT762SVFBYFFTQFVSM3SIghsaWEB
- pkg.go.dev/vuln/GO-2022-0380ghsaWEB
- www.openwall.com/lists/oss-security/2020/11/02/2ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.