netbirdio/netbird · error · ErrTokenAlreadyUsed
JWT already used
Error message
JWT already used
What it means
auth.ErrTokenAlreadyUsed is returned by SessionStore.RegisterToken, which implements single-use JWT claiming: it stores a sha256 marker of the token in a cache until its exp time and returns this error when the marker already exists. The management gRPC login server calls it via claimLoginToken (management/internals/shared/grpc/server.go:948) and maps it to codes.Unauthenticated, so replaying a login JWT is rejected at Login.
Source
Thrown at management/server/auth/session.go:21
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/eko/gocache/lib/v4/cache"
"github.com/eko/gocache/lib/v4/store"
)
const (
usedTokenKeyPrefix = "jwt-used:"
usedTokenMarker = "1"
)
var (
ErrTokenAlreadyUsed = errors.New("JWT already used")
ErrTokenExpired = errors.New("JWT expired")
)
type SessionStore struct {
cache *cache.Cache[string]
}
func NewSessionStore(cacheStore store.StoreInterface) *SessionStore {
return &SessionStore{cache: cache.New[string](cacheStore)}
}
// RegisterToken records a JWT until its exp time and rejects reuse.
func (s *SessionStore) RegisterToken(ctx context.Context, token string, expiresAt time.Time) error {
ttl := time.Until(expiresAt)
if ttl <= 0 {
return ErrTokenExpired
}
View on GitHub (pinned to 93e97f4bf1)
Solutions
- Run the login/device-authorization flow again to obtain a fresh JWT and retry Login with the new token.
- Make the client consume the JWT exactly once: mark it used locally after the first Login attempt and never resend it.
- Give each peer or daemon its own token instead of sharing one JWT across installations.
Example fix
// before
resp, err := client.Login(ctx, &proto.LoginRequest{JwtToken: jwt})
if err != nil {
resp, err = client.Login(ctx, &proto.LoginRequest{JwtToken: jwt}) // replay: rejected
}
// after
resp, err := client.Login(ctx, &proto.LoginRequest{JwtToken: jwt})
if status.Code(err) == codes.Unauthenticated {
jwt, err = fetchNewLoginToken(ctx) // fresh token, then retry once
if err != nil { return err }
resp, err = client.Login(ctx, &proto.LoginRequest{JwtToken: jwt})
} Defensive patterns
Strategy: retry
Try / catch
err := s.sessionStore.RegisterToken(ctx, token, exp)
if errors.Is(err, auth.ErrTokenAlreadyUsed) {
// single-use token was replayed: fetch a NEW token and retry the login once
token = fetchNewLoginToken(ctx)
err = s.sessionStore.RegisterToken(ctx, token, exp)
}
if err != nil {
return err
} Prevention
- Treat login JWTs as single-use: consume once, then discard and re-run the auth flow for the next login.
- Never share one login token across multiple agents or machines.
- Distinguish Unauthenticated-because-reused from other failures before retrying: only the former is fixed by a new token.
- Back the SessionStore cache with a shared store (e.g. Redis) when running multiple management replicas, so reuse detection is global.
When it happens
Trigger: An agent calls the Login gRPC twice with the same jwtToken, e.g. a retry after the first attempt already succeeded server-side; a second peer or daemon instance reuses the same login token; a duplicated request after a client timeout.
Common situations: Retry loops that resend the identical JWT instead of fetching a new one; two machines configured with the same token for convenience; token replay by a copied client config; the marker lives until exp, so even a much later replay within the token lifetime fails.
Related errors
- failed to create auth client: %v
- failed to check SSO support: %v
- failed to check login requirement: %v
- Run `netbird profile list --show-id` to see IDs, then selec
- client not started
AI-assisted analysis of netbirdio/netbird@93e97f4bf1 (2026-08-16).
Data as JSON: /api/errors/49269cf60552e270.
Report an issue: GitHub.