Improper Restriction of Excessive Authentication Attempts in heroiclabs/nakama
Description
Nakama before 3.13.0 lacked rate limiting on login attempts, enabling brute-force attacks; a fix added IP-based and account-based lockout.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Nakama before 3.13.0 lacked rate limiting on login attempts, enabling brute-force attacks; a fix added IP-based and account-based lockout.
Vulnerability
CVE-2022-2321 is an improper restriction of excessive authentication attempts in the Nakama game backend server (versions prior to 3.13.0). The server did not enforce any rate limiting or lockout mechanism on the console login endpoint, allowing an attacker to submit an unlimited number of authentication requests without penalty [1][2].
Exploitation
An attacker can exploit this by repeatedly sending login requests with different credentials, either from a single IP or distributed across multiple IPs. The initial fix introduced a login attempt cache that tracks failed attempts per username and IP address [2]. However, as noted in the pull request discussion, the IP extraction logic used the first element of the X-Forwarded-For header, which is user-controlled, allowing an attacker to spoof their IP and bypass the rate limiter [4]. Additionally, storing arbitrarily long usernames in the cache could lead to memory exhaustion [4].
Impact
Successful brute-force attacks could allow an attacker to guess valid console administrator credentials, gaining unauthorized access to the Nakama console and potentially compromising the entire game backend, including user data, matchmaking, and server configuration [1][3].
Mitigation
The vulnerability is fixed in Nakama version 3.13.0 [2][3]. Users should upgrade immediately. As a workaround, administrators can implement external rate limiting or firewall rules to restrict login attempts, and ensure that reverse proxies strip or validate the X-Forwarded-For header to prevent spoofing [4].
- GitHub - heroiclabs/nakama: Scalable open-source game backend server: multiplayer, matchmaking, leaderboards, chat, and social features for games.
- Better limit for unsuccessful login attempts on the devconsole. (#878) · heroiclabs/nakama@e2e02fc
- NVD - CVE-2022-2321
- Limit unsuccessful login attempts on the console by ftkg · Pull Request #878 · heroiclabs/nakama
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/heroiclabs/nakama/v3Go | <= 3.12.0 | — |
Affected products
2- Range: unspecified
Patches
1e2e02fce80ffBetter limit for unsuccessful login attempts on the devconsole. (#878)
4 files changed · +220 −7
main.go+3 −1 modified@@ -141,6 +141,7 @@ func main() { sessionRegistry := server.NewLocalSessionRegistry(metrics) sessionCache := server.NewLocalSessionCache(config.GetSession().TokenExpirySec) consoleSessionCache := server.NewLocalSessionCache(config.GetConsole().TokenExpirySec) + loginAttemptCache := server.NewLocalLoginAttemptCache() statusRegistry := server.NewStatusRegistry(logger, config, sessionRegistry, jsonpbMarshaler) tracker := server.StartLocalTracker(logger, config, sessionRegistry, statusRegistry, metrics, jsonpbMarshaler) router := server.NewLocalMessageRouter(sessionRegistry, tracker, jsonpbMarshaler) @@ -166,7 +167,7 @@ func main() { statusHandler := server.NewLocalStatusHandler(logger, sessionRegistry, matchRegistry, tracker, metrics, config.GetName()) apiServer := server.StartApiServer(logger, startupLogger, db, jsonpbMarshaler, jsonpbUnmarshaler, config, socialClient, leaderboardCache, leaderboardRankCache, sessionRegistry, sessionCache, statusRegistry, matchRegistry, matchmaker, tracker, router, streamManager, metrics, pipeline, runtime) - consoleServer := server.StartConsoleServer(logger, startupLogger, db, config, tracker, router, streamManager, sessionCache, consoleSessionCache, statusRegistry, statusHandler, runtimeInfo, matchRegistry, configWarnings, semver, leaderboardCache, leaderboardRankCache, apiServer, cookie) + consoleServer := server.StartConsoleServer(logger, startupLogger, db, config, tracker, router, streamManager, sessionCache, consoleSessionCache, loginAttemptCache, statusRegistry, statusHandler, runtimeInfo, matchRegistry, configWarnings, semver, leaderboardCache, leaderboardRankCache, apiServer, cookie) gaenabled := len(os.Getenv("NAKAMA_TELEMETRY")) < 1 const gacode = "UA-89792135-1" @@ -232,6 +233,7 @@ func main() { sessionCache.Stop() sessionRegistry.Stop() metrics.Stop(logger) + loginAttemptCache.Stop() if gaenabled { _ = ga.SendSessionStop(telemetryClient, gacode, cookie)
server/console_authenticate.go+40 −5 modified@@ -20,17 +20,17 @@ import ( "database/sql" "errors" "fmt" - "github.com/gofrs/uuid" - "google.golang.org/protobuf/types/known/emptypb" "time" + "github.com/gofrs/uuid" jwt "github.com/golang-jwt/jwt/v4" "github.com/heroiclabs/nakama/v3/console" "github.com/jackc/pgtype" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" ) type ConsoleTokenClaims struct { @@ -71,6 +71,11 @@ func parseConsoleToken(hmacSecretByte []byte, tokenString string) (id, username, } func (s *ConsoleServer) Authenticate(ctx context.Context, in *console.AuthenticateRequest) (*console.ConsoleSession, error) { + ip, _ := extractClientAddressFromContext(s.logger, ctx) + if !s.loginAttemptCache.Allow(in.Username, ip) { + return nil, status.Error(codes.ResourceExhausted, "Try again later.") + } + role := console.UserRole_USER_ROLE_UNKNOWN var uname string var email string @@ -81,10 +86,20 @@ func (s *ConsoleServer) Authenticate(ctx context.Context, in *console.Authentica role = console.UserRole_USER_ROLE_ADMIN uname = in.Username id = uuid.Nil + } else { + if lockout, until := s.loginAttemptCache.Add(s.config.GetConsole().Username, ip); lockout != LockoutTypeNone { + switch lockout { + case LockoutTypeAccount: + s.logger.Info(fmt.Sprintf("Console admin account locked until %v.", until)) + case LockoutTypeIp: + s.logger.Info(fmt.Sprintf("Console admin IP locked until %v.", until)) + } + } + return nil, status.Error(codes.Unauthenticated, "Invalid credentials.") } default: var err error - id, uname, email, role, err = s.lookupConsoleUser(ctx, in.Username, in.Password) + id, uname, email, role, err = s.lookupConsoleUser(ctx, in.Username, in.Password, ip) if err != nil { return nil, err } @@ -94,7 +109,10 @@ func (s *ConsoleServer) Authenticate(ctx context.Context, in *console.Authentica return nil, status.Error(codes.Unauthenticated, "Invalid credentials.") } + s.loginAttemptCache.Reset(uname) + exp := time.Now().UTC().Add(time.Duration(s.config.GetConsole().TokenExpirySec) * time.Second).Unix() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, &ConsoleTokenClaims{ ExpiresAt: exp, ID: id.String(), @@ -132,19 +150,28 @@ func (s *ConsoleServer) AuthenticateLogout(ctx context.Context, in *console.Auth return &emptypb.Empty{}, nil } -func (s *ConsoleServer) lookupConsoleUser(ctx context.Context, unameOrEmail, password string) (id uuid.UUID, uname string, email string, role console.UserRole, err error) { +func (s *ConsoleServer) lookupConsoleUser(ctx context.Context, unameOrEmail, password, ip string) (id uuid.UUID, uname string, email string, role console.UserRole, err error) { role = console.UserRole_USER_ROLE_UNKNOWN query := "SELECT id, username, email, role, password, disable_time FROM console_user WHERE username = $1 OR email = $1" var dbPassword []byte var dbDisableTime pgtype.Timestamptz err = s.db.QueryRowContext(ctx, query, unameOrEmail).Scan(&id, &uname, &email, &role, &dbPassword, &dbDisableTime) if err != nil { if err == sql.ErrNoRows { - err = nil + if lockout, until := s.loginAttemptCache.Add("", ip); lockout == LockoutTypeIp { + s.logger.Info(fmt.Sprintf("Console user IP locked until %v.", until)) + } + err = status.Error(codes.Unauthenticated, "Invalid credentials.") } return } + // Check lockout again as the login attempt may have been through email. + if !s.loginAttemptCache.Allow(uname, ip) { + err = status.Error(codes.ResourceExhausted, "Try again later.") + return + } + // Check if it's disabled. if dbDisableTime.Status == pgtype.Present && dbDisableTime.Time.Unix() != 0 { s.logger.Info("Console user account is disabled.", zap.String("username", unameOrEmail)) @@ -155,6 +182,14 @@ func (s *ConsoleServer) lookupConsoleUser(ctx context.Context, unameOrEmail, pas // Check password err = bcrypt.CompareHashAndPassword(dbPassword, []byte(password)) if err != nil { + if lockout, until := s.loginAttemptCache.Add(uname, ip); lockout != LockoutTypeNone { + switch lockout { + case LockoutTypeAccount: + s.logger.Info(fmt.Sprintf("Console user account locked until %v.", until)) + case LockoutTypeIp: + s.logger.Info(fmt.Sprintf("Console user IP locked until %v.", until)) + } + } err = status.Error(codes.Unauthenticated, "Invalid credentials.") return }
server/console.go+3 −1 modified@@ -138,6 +138,7 @@ type ConsoleServer struct { StreamManager StreamManager sessionCache SessionCache consoleSessionCache SessionCache + loginAttemptCache LoginAttemptCache statusRegistry *StatusRegistry matchRegistry MatchRegistry statusHandler StatusHandler @@ -155,7 +156,7 @@ type ConsoleServer struct { httpClient *http.Client } -func StartConsoleServer(logger *zap.Logger, startupLogger *zap.Logger, db *sql.DB, config Config, tracker Tracker, router MessageRouter, streamManager StreamManager, sessionCache SessionCache, consoleSessionCache SessionCache, statusRegistry *StatusRegistry, statusHandler StatusHandler, runtimeInfo *RuntimeInfo, matchRegistry MatchRegistry, configWarnings map[string]string, serverVersion string, leaderboardCache LeaderboardCache, leaderboardRankCache LeaderboardRankCache, api *ApiServer, cookie string) *ConsoleServer { +func StartConsoleServer(logger *zap.Logger, startupLogger *zap.Logger, db *sql.DB, config Config, tracker Tracker, router MessageRouter, streamManager StreamManager, sessionCache SessionCache, consoleSessionCache SessionCache, loginAttemptCache LoginAttemptCache, statusRegistry *StatusRegistry, statusHandler StatusHandler, runtimeInfo *RuntimeInfo, matchRegistry MatchRegistry, configWarnings map[string]string, serverVersion string, leaderboardCache LeaderboardCache, leaderboardRankCache LeaderboardRankCache, api *ApiServer, cookie string) *ConsoleServer { var gatewayContextTimeoutMs string if config.GetConsole().IdleTimeoutMs > 500 { // Ensure the GRPC Gateway timeout is just under the idle timeout (if possible) to ensure it has priority. @@ -182,6 +183,7 @@ func StartConsoleServer(logger *zap.Logger, startupLogger *zap.Logger, db *sql.D StreamManager: streamManager, sessionCache: sessionCache, consoleSessionCache: consoleSessionCache, + loginAttemptCache: loginAttemptCache, statusRegistry: statusRegistry, matchRegistry: matchRegistry, statusHandler: statusHandler,
server/login_attempt_cache.go+174 −0 added@@ -0,0 +1,174 @@ +// Copyright 2022 The Nakama Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "context" + "sync" + "time" +) + +type LockoutType uint8 + +const ( + LockoutTypeNone LockoutType = iota + LockoutTypeAccount + LockoutTypeIp +) + +const ( + maxAttemptsAccount = 5 + lockoutPeriodAccount = time.Minute * 1 + + maxAttemptsIp = 10 + lockoutPeriodIp = time.Minute * 10 +) + +type LoginAttemptCache interface { + Stop() + // Allow checks whether account or IP is locked out or should be allowed to attempt to authenticate. + Allow(account, ip string) bool + // Add a failed attempt and return current lockout status. + Add(account, ip string) (LockoutType, time.Time) + // Reset account attempts on successful login. + Reset(account string) +} + +type lockoutStatus struct { + lockedUntil time.Time + attempts []time.Time +} + +func (ls *lockoutStatus) trim(now time.Time, retentionPeriod time.Duration) bool { + if ls.lockedUntil.Before(now) { + ls.lockedUntil = time.Time{} + } + for i := len(ls.attempts) - 1; i >= 0; i-- { + if now.Sub(ls.attempts[i]) >= retentionPeriod { + ls.attempts = ls.attempts[i+1:] + break + } + } + + return ls.lockedUntil.IsZero() && len(ls.attempts) == 0 +} + +type LocalLoginAttemptCache struct { + sync.RWMutex + ctx context.Context + ctxCancelFn context.CancelFunc + + accountCache map[string]*lockoutStatus + ipCache map[string]*lockoutStatus +} + +func NewLocalLoginAttemptCache() LoginAttemptCache { + ctx, ctxCancelFn := context.WithCancel(context.Background()) + + c := &LocalLoginAttemptCache{ + accountCache: make(map[string]*lockoutStatus), + ipCache: make(map[string]*lockoutStatus), + + ctx: ctx, + ctxCancelFn: ctxCancelFn, + } + + go func() { + ticker := time.NewTicker(10 * time.Minute) + for { + select { + case <-c.ctx.Done(): + ticker.Stop() + return + case t := <-ticker.C: + now := t.UTC() + c.Lock() + for account, status := range c.accountCache { + if status.trim(now, lockoutPeriodAccount) { + delete(c.accountCache, account) + } + } + for ip, status := range c.ipCache { + if status.trim(now, lockoutPeriodIp) { + delete(c.ipCache, ip) + } + } + c.Unlock() + } + } + }() + + return c +} + +func (c *LocalLoginAttemptCache) Stop() { + c.ctxCancelFn() +} + +func (c *LocalLoginAttemptCache) Allow(account, ip string) bool { + now := time.Now().UTC() + c.RLock() + defer c.RUnlock() + if status, found := c.accountCache[account]; found && !status.lockedUntil.IsZero() && status.lockedUntil.After(now) { + return false + } + if status, found := c.ipCache[ip]; found && !status.lockedUntil.IsZero() && status.lockedUntil.After(now) { + return false + } + return true +} + +func (c *LocalLoginAttemptCache) Reset(account string) { + c.Lock() + delete(c.accountCache, account) + c.Unlock() +} + +func (c *LocalLoginAttemptCache) Add(account, ip string) (LockoutType, time.Time) { + now := time.Now().UTC() + var lockoutType LockoutType + var lockedUntil time.Time + c.Lock() + defer c.Unlock() + if account != "" { + status, found := c.accountCache[account] + if !found { + status = &lockoutStatus{} + c.accountCache[account] = status + } + status.attempts = append(status.attempts, now) + _ = status.trim(now, lockoutPeriodAccount) + if len(status.attempts) >= maxAttemptsAccount { + status.lockedUntil = now.Add(lockoutPeriodAccount) + lockedUntil = status.lockedUntil + lockoutType = LockoutTypeAccount + } + } + if ip != "" { + status, found := c.ipCache[ip] + if !found { + status = &lockoutStatus{} + c.ipCache[ip] = status + } + status.attempts = append(status.attempts, now) + _ = status.trim(now, lockoutPeriodIp) + if len(status.attempts) >= maxAttemptsIp { + status.lockedUntil = now.Add(lockoutPeriodIp) + lockedUntil = status.lockedUntil + lockoutType = LockoutTypeIp + } + } + return lockoutType, lockedUntil +}
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-8r94-4h3c-939fghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-2321ghsaADVISORY
- github.com/heroiclabs/nakama/commit/e2e02fce80ff33ce45f8a6ebc0b7a99ee0b03824ghsax_refsource_MISCWEB
- github.com/heroiclabs/nakama/pull/878ghsaWEB
- huntr.dev/bounties/3055b3f5-6b80-4d47-8e00-3500dfb458bcghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.