Unauthorized api after 15 minutes after upgrading openziti/quickstart to 2.0.0

After updating openziti/quickstart docker image to 2.0.0. my keep-alive requests dont work anymore.

when i start my api, i can create admins(on create it creates openziti identity for that admin). after 10-15 minutes i cant create new admins anymore. i get this error:
api-server-1 | 2026/09/03 13:10:31 identity.go:126: {"error":{"code":"UNAUTHORIZED","message":"The request could not be completed. The session is not authorized or the credentials are invalid","requestId":"3U6cz5iBO"},"meta":{"apiEnrollmentVersion":"0.0.1","apiVersion":"0.0.1"}}

my code has keepalive requests every 10 minutes

my code:

func SetupOpenziti(ctrlUrl string, zitiIDPath string) error {

    err := EnrollIfNeeded(zitiIDPath)

if err != nil {

log.Print(err)

return err

    }

err = CreateApiSession(ctrlUrl, zitiIDPath)

if err != nil {

log.Print(err)

return err

    }

err = InitCon(zitiIDPath)

if err != nil {

log.Print(err)

return err

    }




go KeepAlive(ctrlUrl)

return nil

}

func CreateApiSession(ctrlUrl string, zitiIDPath string) error {

    // Read the JSON file

jsonFile, err := os.ReadFile(zitiIDPath + ".json")

if err != nil {

log.Print(err)

return err

    }




var jsonData map[string]interface{}

err = json.Unmarshal(jsonFile, &jsonData)

if err != nil {

log.Print(err)

return err

    }




// Create a new map[interface{}]interface{}

convertedMap := make(map[interface{}]interface{})




// Copy each key-value pair from the original map to the new map

for key, value := range jsonData["id"].(map[string]interface{}) {

convertedMap[key] = value

    }




// Get api session from openziti

urls := make([]*url.URL, 1)

apiUrl, _ := url.Parse(ctrlUrl + "/edge/management/v1")

urls[0] = apiUrl




// Create Identitiy credentials from .json identity

conf, err := identity.NewConfigFromMap(convertedMap)

if err != nil {

log.Print(err)

return err

    }

credentials := edge_apis.NewIdentityCredentialsFromConfig(*conf)




// Authenticate and get token

var configTypes []string

managementClient := edge_apis.NewManagementApiClient(urls, credentials.GetCaPool(), func(ch chan string) {})

apiSesionDetial, err := managementClient.Authenticate(credentials, configTypes)

if err != nil {

log.Print(err)

return err

    }

_, token := apiSesionDetial.GetAccessHeader()

SessionToken = token




if os.Getenv("DEV_ENV") == "true" {

log.Printf("Ziti session token: %s", SessionToken)

    } else {

log.Print("Created ziti api session")

    }

return nil

}




// Pings openziti controller every X seconds to keep the api session alive

func KeepAlive(ctrlUrl string) {

ticker := time.NewTicker(time.Minute * 10)




for range ticker.C {

// Make the HTTPS GET request

url := ctrlUrl + "/edge/management/v1/"

req, err := http.NewRequest("GET", url, nil)

if err != nil {

log.Print(err)

return

        }

req.Header.Set("Content-Type", "application/json")

req.Header.Add("zt-session", SessionToken)




// Perform the request

client := &http.Client{}

resp, err := client.Do(req)

if err != nil {

log.Print(err)

return

        }

defer resp.Body.Close()




// Read the response body

body, err := io.ReadAll(resp.Body)

if err != nil {

log.Print(err)

return

        }




// Print the response body

log.Printf("Performed keep alive for openziti session response: %s", body)

    }

}

are there any breaking changes in this? My previous version of openziti/quickstart was 1.6.6 i upgraded to 2.0.0. What do i need to do, so my api can call openziti api even after 10-20 or more minutes.

Any help would be greatly appreciated!

This looks like a bug.

What i tried:
updated ziti-controller.yaml with:
api:
#(optional, default 90s) Alters how frequently heartbeat and last activity values are persisted
# activityUpdateInterval: 90s
#(optional, default 250) The number of API Sessions updated for last activity per transaction
# activityUpdateBatchSize: 250
# sessionTimeout - optional, default 30m
# The number of minutes before an Edge API session will time out. Timeouts are reset by
# API requests and connections that are maintained to Edge Routers
sessionTimeout: 2m

updated my keep alive code to ping every 1 minute:

func KeepAlive(ctrlUrl string) {

    ticker := time.NewTicker(time.Minute * 1)

restarted by docker containers. i tried creating new identities, it worked for 2 minutes, then after 2 minutes passed i get unauthorized response.

documentation clearly states:

    # sessionTimeout - optional, default 30m

    # The number of minutes before an Edge API session will time out. Timeouts are reset by

# API requests and connections that are maintained to Edge Routers

So the keep alives should work, as it did before.

my sdk versions:

github.com/openziti/channel/v4 v4.2.21 // indirect

    github.com/openziti/edge-api v0.26.47 // indirect

github.com/openziti/foundation/v2 v2.0.70 // indirect

github.com/openziti/identity v1.0.109 // indirect

github.com/openziti/metrics v1.4.2 // indirect

github.com/openziti/sdk-golang v1.2.2 // indirect

github.com/openziti/secretstream v0.1.38 // indirect

github.com/openziti/transport/v2 v2.0.183 // indirect

github.com/openziti/ziti v1.6.7 // indirect

updating openziti sdk versions did not fix the issue:

    github.com/openziti/channel/v5 v5.0.29 // indirect 
   github.com/openziti/edge-api v0.36.0 // indirect    
github.com/openziti/foundation/v2 v2.0.100 // indirect   
 github.com/openziti/identity v1.0.140 // indirect    
github.com/openziti/metrics v1.4.5 // indirect    
github.com/openziti/sdk-golang v1.9.0 // indirect    
github.com/openziti/secretstream v0.1.52 // indirect   
 github.com/openziti/transport/v2 v2.0.221 // indirect

Hi @CarlosHleb , I did some digging into this, and it looks like a regression in 2.0.+ where hitting the REST API is no longer extending legacy sessions. I'm going to work on getting a fix out. In the meantime, here's some example code that shows how to solve the issue today. The default for 2.0 is JWT session using OIDC, which need to be refreshed explicitly.

This is example code generated by claude:

  package zitimgmt

  import (
        "errors"
        "log"
        "math/rand"
        "net/url"
        "sync"
        "time"

        "github.com/go-openapi/runtime"
        "github.com/openziti/edge-api/rest_management_api_client"
        "github.com/openziti/edge-api/rest_util"
        "github.com/openziti/sdk-golang/edge-apis"
        "github.com/openziti/sdk-golang/ziti"
  )

  // Client wraps the SDK management client and keeps its API session alive.
  // Use Do for API calls; it retries once after re-authenticating on a 401.
  type Client struct {
        api   *edge_apis.ManagementApiClient
        creds edge_apis.Credentials

        mu      sync.Mutex // serializes authenticate/refresh
        closeCh chan struct{}
  }

  // New authenticates against the management API at ctrlUrl using an enrolled
  // identity file and starts a background session refresher. Call Close when done.
  func New(ctrlUrl string, identityFile string) (*Client, error) {
        cfg, err := ziti.NewConfigFromFile(identityFile)
        if err != nil {
                return nil, err
        }

        mgmtUrl, err := url.Parse(ctrlUrl + "/edge/management/v1")
        if err != nil {
                return nil, err
        }

        creds := edge_apis.NewIdentityCredentialsFromConfig(cfg.ID)

        api := edge_apis.NewManagementApiClient([]*url.URL{mgmtUrl}, creds.GetCaPool(), func(chan string) {})
        // Use OIDC when the controller offers it. OIDC sessions are extended by
        // exchanging the refresh token, which the refresher below does.
        api.SetAllowOidcDynamicallyEnabled(true)

        c := &Client{api: api, creds: creds, closeCh: make(chan struct{})}
  
        if err = c.authenticate(); err != nil {
                return nil, err
        }

        go c.runRefresher()
        return c, nil
  }

  // API exposes the generated management API. Pass nil for authInfo on calls;
  // the client attaches the current session automatically.
  func (c *Client) API() *rest_management_api_client.ZitiEdgeManagement {
        return c.api.API.ZitiEdgeManagement
  }

  // Do runs f and, if it fails with 401, re-authenticates and runs it once more.
  func (c *Client) Do(f func(api *rest_management_api_client.ZitiEdgeManagement) error) error {
        err := f(c.API())
        if !isUnauthorized(err) {
                return rest_util.WrapErr(err)
        }

        if authErr := c.authenticate(); authErr != nil {
                return authErr
        }
        return rest_util.WrapErr(f(c.API()))
  }

  func (c *Client) Close() {
        close(c.closeCh)
  }

  func (c *Client) authenticate() error {
        c.mu.Lock()
        defer c.mu.Unlock()

        _, err := c.api.Authenticate(c.creds, nil)
        return err
  }

  // refresh extends the current session, falling back to a full authenticate.
  func (c *Client) refresh() error {
        c.mu.Lock()
        defer c.mu.Unlock()

        cur := c.api.GetCurrentApiSession()
        if cur != nil {
                if _, err := c.api.AuthenticateWithPreviousSession(c.creds, cur); err == nil {
                        return nil
                } else {
                        log.Printf("ziti api session refresh failed, re-authenticating: %v", err)
                }
        }

        _, err := c.api.Authenticate(c.creds, nil)
        return err
  }

  func (c *Client) runRefresher() {
        for {
                select {
                case <-c.closeCh:
                        return
                case <-time.After(time.Until(c.nextRefresh())):
                        if err := c.refresh(); err != nil {
                                log.Printf("ziti api session refresh failed: %v", err)
                        }
                }
        }
  }

  // nextRefresh picks a time between 1/2 and 5/6 of the session's remaining
  // life, or 5s from now if we have no session or no expiry.
  func (c *Client) nextRefresh() time.Time {
        cur := c.api.GetCurrentApiSession()
        if cur == nil || cur.GetExpiresAt() == nil {
                return time.Now().Add(5 * time.Second)
        }
        remaining := time.Until(*cur.GetExpiresAt())
        if remaining <= 0 {
                return time.Now()
        }
        return time.Now().Add(remaining/2 + time.Duration(rand.Int63n(int64(remaining/3))))
  }

  func isUnauthorized(err error) bool {
        if err == nil {
                return false
        }
        var coded interface{ Code() int }
        if errors.As(err, &coded) {
                return coded.Code() == 401
        }
        var apiErr *runtime.APIError
        return errors.As(err, &apiErr) && apiErr.Code == 401
  }

Here is how the example code would be used:


  client, err := zitimgmt.New(ctrlUrl, zitiIDPath+".json")
  if err != nil {
        log.Fatal(err)
  }
  defer client.Close()

  idType := rest_model.IdentityTypeDefault
  isAdmin := false

  err = client.Do(func(api *rest_management_api_client.ZitiEdgeManagement) error {
        params := identity.NewCreateIdentityParams().WithIdentity(&rest_model.IdentityCreate{
                Name:       &name,
                Type:       &idType,
                IsAdmin:    &isAdmin,
                Enrollment: &rest_model.IdentityCreateEnrollment{Ott: true},
        })
        resp, err := api.Identity.CreateIdentity(params, nil)
        if err != nil {
                return err
        }
        log.Printf("created identity %s", resp.Payload.Data.ID)
        return nil
  })

I'll let you know when I've got a fix for the legacy session refresh.

Paul

Hello,

Awesome. Please let me know when a new version of openziti/quickstart comes out with the fix.

Thank you!

Hello,

Its been 18 days. Can you give us some kind of timeline when this can be fixed and new docker openziti/quickstart image can be pushed?

Hi @CarlosHleb ,
Apologies, I lost track of this discourse thread. The issue (REST requests no longer reset the legacy API session timeout · Issue #4365 · openziti/ziti · GitHub) is fixed in OpenZiti 2.0.5. Version 2.0.6 was released shortly thereafter, with some additional fixes, that's currently soaking and should be marked as stable/latest at the end of the week.

Let us know if you still see issues after upgrading.

Thank you,
Paul

Awesome, thank you for the fix!

Inside openziti/quickstart - Docker Image .
It shows the last image was released 4 months ago. Can you make a docker quickstart release with a fix?

Taking a look to see why newer releases don't have quickstart docker images.

any update on this? i see there is a new quickstart release with 2.0.4, but you said fix is in 2.0.5