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

  1. Run the login/device-authorization flow again to obtain a fresh JWT and retry Login with the new token.
  2. Make the client consume the JWT exactly once: mark it used locally after the first Login attempt and never resend it.
  3. 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

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


AI-assisted analysis of netbirdio/netbird@93e97f4bf1 (2026-08-16). Data as JSON: /api/errors/49269cf60552e270. Report an issue: GitHub.