semaphoreui/semaphore · error

Unauthorized

Error message

Unauthorized

What it means

This is the HTTP 401 response returned by the /metrics endpoint's Basic Auth middleware in Semaphore's API. The middleware wraps the Prometheus metrics handler and requires a username/password pair (from the metrics section of the config) to be supplied via HTTP Basic authentication, compared using constant-time comparison to prevent timing attacks. If metrics are disabled, credentials are not configured, or the supplied Basic Auth credentials do not match, it responds 'Unauthorized' with a WWW-Authenticate challenge header.

Solutions

  1. Set basic_auth credentials in the monitoring client (e.g. Prometheus scrape_config basic_auth user/password) to match the metrics user/password in Semaphore's config
  2. Ensure util.Config.Metrics.Enabled is true and both username and password are non-empty in Semaphore's config
  3. Test with: curl -u <user>:<pass> http://<host>:<port>/api/metrics
  4. If credentials changed, restart/reload Semaphore and update the scraper's stored credentials

Example fix

# before (Prometheus scrape without auth -> 401)
scrape_configs:
  - job_name: semaphore
    static_configs:
      - targets: ['semaphore:3000']
# after
scrape_configs:
  - job_name: semaphore
    basic_auth:
      username: metrics_user
      password: metrics_password
    static_configs:
      - targets: ['semaphore:3000']
Defensive patterns

Strategy: validation

Validate before calling

// before scraping /metrics, verify credentials work:
resp, err := http.Get("http://host:3000/api/metrics") // expect 401 without auth
req, _ := http.NewRequest("GET", "http://host:3000/api/metrics", nil)
req.SetBasicAuth(user, pass)
resp, err = http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != 200 { /* fix credentials/config before relying on metrics */ }

Type guard

func metricsConfigValid(enabled bool, user, pass string) bool {
    return enabled && user != "" && pass != ""
}

Prevention

When it happens

Trigger: GET /metrics without an Authorization: Basic header; with a malformed Basic header; with a username or password that does not match util.Config.Metrics (user/password); or when util.Config.Metrics.Enabled is false / username or password are empty strings in config.

Common situations: Monitoring systems (Prometheus, Grafana agent, curl health checks) scraping /metrics without the scrape config's basic_auth credentials; Metrics.Enabled missing/false in config while still probing the endpoint; credentials rotated in config but not in the scraper; special characters in the password that are not correctly base64-encoded in the client.

Understand the failure class

Related errors


AI-assisted analysis of semaphoreui/semaphore@1774ccb71a (2026-09-07). Data as JSON: /api/errors/03d3535d97c372bf. Report an issue: GitHub.

Appendix: source

Thrown at api/auth.go:335

			return
		}

		next.ServeHTTP(w, r)
	})
}

func metricsAuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		username := util.Config.Metrics.Username
		password := util.Config.Metrics.Password

		reqUser, reqPass, ok := r.BasicAuth()
		userMatch := subtle.ConstantTimeCompare([]byte(reqUser), []byte(username)) == 1
		passMatch := subtle.ConstantTimeCompare([]byte(reqPass), []byte(password)) == 1

		if !util.Config.Metrics.Enabled || username == "" || password == "" || !ok || !userMatch || !passMatch {
			w.Header().Set("WWW-Authenticate", `Basic realm="metrics"`)
			http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
			return
		}

		next.ServeHTTP(w, r)
	})
}

// isStateChangingMethod reports whether an HTTP method can modify server state
// and therefore requires CSRF protection. Safe methods (GET, HEAD, OPTIONS,
// TRACE) are excluded.
func isStateChangingMethod(method string) bool {
	switch method {
	case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
		return true
	default:
		return false
	}
}

View on GitHub (pinned to 1774ccb71a)