Horizon UI ships with no accounts at all, and a chart install that skips this page produces a Deployment that reports healthy while nobody can sign in. This page shows how to confirm that state, how to seed a throwaway demo login, and how to configure real users from a Kubernetes Secret.
Both paths set one variable, HORIZON_AUTH_LOCAL_USERS, whose value is a JSON array of users. The image's /app/horizon.yaml reads it (users: ${HORIZON_AUTH_LOCAL_USERS:[]}), and the chart mounts nothing over that file by default — so an environment variable is all it takes.
Horizon has no built-in admin/admin fallback, and the chart configures no users of its own. The BFF does not fail closed when it finds none: it boots, logs an error, serves the login page, and answers its readiness probe with 200. The result is a green deployment nobody can use.
| what you see | what is actually happening |
|---|---|
Pod 1/1 Running, Ready | ui.readinessProbe hits /api/auth/health, which is public and always answers 200 |
| Login page renders, with a setup banner | The page reads configured: false from that same endpoint |
| Every username/password is rejected | auth.local.users is empty, so no credential can match |
Confirm it from outside the pod — configured and setupHint are the two fields that matter:
kubectl port-forward -n "${SKYWALKING_RELEASE_NAMESPACE}" \ svc/${SKYWALKING_RELEASE_NAME}-skywalking-helm-ui 8080:80 curl -s http://127.0.0.1:8080/api/auth/health
{"backend":"local","configured":false,"setupHint":"No users configured. Add at least one entry to auth.local.users in horizon.yaml ...","ldap":null,"breakGlass":{"armed":false}}
The same state appears once in the UI container log at startup:
kubectl logs -n "${SKYWALKING_RELEASE_NAMESPACE}" \ deploy/${SKYWALKING_RELEASE_NAME}-skywalking-helm-ui | grep 'auth.local.users is empty'
For a first run on a trusted network, paste this into a values file. It seeds admin/admin and skywalking/skywalking using argon2id hashes of those exact plaintexts — byte-for-byte what test/e2e/values.yaml feeds the chart's own e2e.
These hashes are published in this repository. Anyone can read them and derive the passwords. Use them only on a network you control, and replace them before the UI is reachable by anyone else.
# demo-values.yaml ui: extraEnv: - name: HORIZON_AUTH_LOCAL_USERS value: >- [{"username":"admin","passwordHash":"$argon2id$v=19$m=65536,t=3,p=4$eemqy1r72oSXR58y8VpRqw$Bn/dULrmJTHEi3263KfgWDEwQmUsqNLi3xwyv/DekHM","roles":["admin"]}, {"username":"skywalking","passwordHash":"$argon2id$v=19$m=65536,t=3,p=4$Zqj8HhQDqm8d5c2MipHYZw$BsaCnu4bdd4uadIldx3wwYLsdo47Thxb7Lv1MXpWG2Q","roles":["viewer","maintainer"]}]
helm install "${SKYWALKING_RELEASE_NAME}" \ oci://docker.io/apache/skywalking-helm \ --version 5.0.0 \ -n "${SKYWALKING_RELEASE_NAMESPACE}" \ --set oap.image.tag=11.0.0 \ --set oap.storageType=banyandb \ --set elasticsearch.enabled=false \ --set banyandb.enabled=true \ --set banyandb.image.tag=0.11.0 \ --set ui.image.tag=horizon-1.0.0 \ -f demo-values.yaml
Then port-forward and log in as admin/admin:
kubectl port-forward -n "${SKYWALKING_RELEASE_NAMESPACE}" \ svc/${SKYWALKING_RELEASE_NAME}-skywalking-helm-ui 8080:80 open http://127.0.0.1:8080
Two rules for the value, both about how it is carried rather than what it means:
--set: a hash is full of , and =, which --set reads as its own separators (and of $, which the shell would expand first).horizon.yaml and then parses the file, so a newline inside the value lands mid-sequence at column 0 and the parse fails — the BFF exits at boot and the pod crash-loops. To wrap it for readability use a folded block (>-) with every continuation line at the same indentation as the first, as above: YAML folds those into single spaces. Indenting a continuation line deeper, or using |-, keeps the newline and breaks the pod.Generate your own hash first. The CLI lives in the Horizon UI repository and reads the password from argv or stdin:
git clone https://github.com/apache/skywalking-horizon-ui.git cd skywalking-horizon-ui && pnpm install HASH=$(pnpm --filter bff cli:hash 'your-strong-password' | tail -1)
Passwords longer than 64 characters are refused — the login route rejects them too, so a hash of one could never be signed in with.
Put the same JSON in a Secret, under the key HORIZON_AUTH_LOCAL_USERS:
kubectl create secret generic horizon-users \ -n "${SKYWALKING_RELEASE_NAMESPACE}" \ --from-literal=HORIZON_AUTH_LOCAL_USERS='[{"username":"admin","passwordHash":"'"$HASH"'","roles":["admin"]}]'
Point the chart at it. ui.envFromSecret becomes an envFrom.secretRef on the UI container, so every key of that Secret arrives as an environment variable — one Secret can carry the users, HORIZON_OAP_AUTH, and anything else sensitive:
# my-values.yaml ui: envFromSecret: horizon-users
helm install "${SKYWALKING_RELEASE_NAME}" \ oci://docker.io/apache/skywalking-helm \ --version 5.0.0 \ -n "${SKYWALKING_RELEASE_NAMESPACE}" \ --set oap.image.tag=11.0.0 \ --set oap.storageType=banyandb \ --set elasticsearch.enabled=false \ --set banyandb.enabled=true \ --set banyandb.image.tag=0.11.0 \ --set ui.image.tag=horizon-1.0.0 \ -f my-values.yaml
Use ui.extraEnv instead when the users live under a different key of a Secret you already have, or when you want only that one key out of it:
ui: extraEnv: - name: HORIZON_AUTH_LOCAL_USERS valueFrom: secretKeyRef: name: horizon-users key: users.json
Check /api/auth/health after the rollout: an empty or missing value falls back to [], which is the silent lockout again. configured: true is the confirmation.
Editing ui.extraEnv changes a pod field, so helm upgrade rolls the UI on its own. Editing the contents of the Secret behind ui.envFromSecret changes no pod field and rolls nothing — and env is read once at container start, so restart it yourself:
kubectl rollout restart -n "${SKYWALKING_RELEASE_NAMESPACE}" \ deploy/${SKYWALKING_RELEASE_NAME}-skywalking-helm-ui
ui.configui.config is empty by default and nothing is mounted. Setting it replaces the image's /app/horizon.yaml with a rendered one — but the chart keeps the ${HORIZON_AUTH_LOCAL_USERS:[]} token in it unless you write users of your own, so the Secret above keeps working either way. Write auth.local.users in ui.config only if you want to pin users regardless of the environment.
roles on a user is a list of role names from rbac.roles. Horizon ships four:
| role | grants |
|---|---|
viewer | Read the data: metrics, traces, logs, alarms, events, topology, profiling, browser errors, overviews, inspect |
maintainer | Viewer, plus platform reads — cluster health, TTL, OAP configuration |
operator | Maintainer, plus writes — dashboard and overview templates, DSL rules, live debugging, profiling tasks, source maps. Alarm rules stay read-only for every role |
admin | * |
A user with an empty roles list can sign in and see nothing. Define your own names with HORIZON_RBAC_ROLES, whose value is the whole rbac.roles block as JSON; see the horizon.yaml reference.
The other auth backends work the same way — one variable, one JSON value, from ui.extraEnv or a Secret via ui.envFromSecret:
| what | variable | value |
|---|---|---|
| Pick the backend | HORIZON_AUTH_BACKEND | local (default) or ldap |
| LDAP directory | HORIZON_AUTH_LDAP | {"url":"ldaps://ldap.corp:636","userBaseDn":"...","groupMappings":[...]} |
| SSO (OIDC/OAuth2) | HORIZON_AUTH_SSO | {"providers":[...],"roles":{...}}; additive to the backend, not a replacement |
| Break-glass account | HORIZON_AUTH_BREAK_GLASS | JSON; honoured only with backend: ldap, and only while the LDAP probe fails |
auth.tokensFile — API tokens for callers with no browser (scripts, CI, MCP clients) — is the exception: HORIZON_AUTH_TOKENS_FILE takes a path, not a value, so the tokens themselves need ui.extraVolumes / ui.extraVolumeMounts. See Configure Horizon.