crowdsecurity/crowdsec · error

unable to parse jwt expiration: %w

Error message

unable to parse jwt expiration: %w

What it means

After a successful watcher authentication, the server-issued expiration timestamp (authResp.Expire) is parsed with time.Time.UnmarshalText, which requires RFC3339 format. If the server returned an empty or malformed expiration string, parsing fails and InitLAPIClient aborts even though the token itself was obtained.

Source

Thrown at pkg/apiclient/client.go:134

		PapiURL:       papiURL,
		VersionPrefix: "v1",
		UpdateScenario: func(_ context.Context) ([]string, error) {
			return scenarios, nil
		},
	})

	authResp, _, err := client.Auth.AuthenticateWatcher(ctx, models.WatcherAuthRequest{
		MachineID: &login,
		Password:  &pwd,
		Scenarios: scenarios,
	})
	if err != nil {
		return fmt.Errorf("authenticate watcher (%s): %w", login, err)
	}

	var expiration time.Time
	if err := expiration.UnmarshalText([]byte(authResp.Expire)); err != nil {
		return fmt.Errorf("unable to parse jwt expiration: %w", err)
	}

	client.GetClient().Transport.(*JWTTransport).Token = authResp.Token
	client.GetClient().Transport.(*JWTTransport).Expiration = expiration

	lapiClient = client

	return nil
}

func GetLAPIClient() (*ApiClient, error) {
	if lapiClient == nil {
		return nil, errors.New("client not initialized")
	}

	return lapiClient, nil
}

View on GitHub (pinned to 909b515798)

Solutions

  1. Check what the LAPI actually returns for the auth endpoint (curl -X POST .../watchers/login) and inspect the 'expirations'/'expire' field format
  2. Align crowdsec client and LAPI server versions; a format change between versions can cause this
  3. If a proxy is involved, verify it passes the JSON body through unmodified
  4. Check that the response is real JSON from LAPI and not an HTML error page (a 200 with wrong body decodes into an empty Expire)
Defensive patterns

Strategy: validation

Validate before calling

// after a successful raw auth call, check the expiration field before parsing
if authResp.Expire == "" {
    return errors.New("server returned empty jwt expiration")
}
if _, err := time.Parse(time.RFC3339, authResp.Expire); err != nil {
    return fmt.Errorf("unexpected expiration format %q: %w", authResp.Expire, err)
}

Type guard

func hasValidExpire(resp *models.WatcherAuthResponse) bool {
    if resp == nil || resp.Expire == "" {
        return false
    }
    _, err := time.Parse(time.RFC3339, resp.Expire)
    return err == nil
}

Prevention

When it happens

Trigger: expiration.UnmarshalText([]byte(authResp.Expire)) fails because authResp.Expire is empty, null, or not in RFC3339 format (e.g. '2026-09-06 12:00:00' without T/timezone, or a non-date string).

Common situations: LAPI behind a proxy that mangles the JSON response; version mismatch between client and server returning a different date format; auth endpoint hit through a wrong path returning HTML instead of the expected JSON; a mocked/test server returning incomplete WatcherAuthResponse.

Understand the failure class

Related errors


AI-assisted analysis of crowdsecurity/crowdsec@909b515798 (2026-09-06). Data as JSON: /api/errors/29242b3584bc80fb. Report an issue: GitHub.