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
- 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
- Ensure util.Config.Metrics.Enabled is true and both username and password are non-empty in Semaphore's config
- Test with: curl -u <user>:<pass> http://<host>:<port>/api/metrics
- 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
- Store metrics credentials in the scraper config alongside the target URL
- Keep config.Metrics.Enabled=true and credentials non-empty when monitoring is expected
- Rotate scraper and server credentials together
- Test the /metrics endpoint with curl -u after every config change
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
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- You must be signed in to link an external account.
- internal error
- Account linking must be initiated with a POST request.
- OIDC sign-in failed: state cookie is missing. Try signing…
- OIDC sign-in failed: invalid state. Try signing in again.
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)