Skip to content

fix(nginx): stop the Control UI Bearer token from clobbering basic auth - #87

Merged
andrasbacsai merged 2 commits into
coollabsio:mainfrom
codezz:fix/control-ui-bearer-vs-basic-auth
Oct 19, 2026
Merged

andrasbacsai merged 2 commits into
coollabsio:mainfrom
codezz:fix/control-ui-bearer-vs-basic-auth

Conversation

@codezz

@codezz codezz commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the repeated basic-auth prompt reported in #65.

Root cause

The Control UI authenticates to the gateway with Authorization: Bearer <token>. A request carries only one Authorization header, so on every fetch the UI makes itself, that Bearer replaces the browser's Basic credentials. auth_basic cannot parse it as Basic, returns 401 + WWW-Authenticate: Basic, and the browser prompts again.

Entering the correct password provably cannot stop it. The password only helps browser-managed requests (navigations, fonts, the WebSocket), and the next fetch the app makes overwrites the header again. It reads as intermittent because it fires when the UI loads workspace icons and avatars.

Measured on a live agent with an nginx log of the auth scheme per request (never the value):

scheme=Basic  user=<user>  status=200   navigations, fonts, WebSocket 101
scheme=Bearer user=-       status=401   GET /control-ui-config.json
scheme=Bearer user=-       status=401   GET /api/users/gateway-owner/avatar
scheme=Bearer user=-       status=401   GET /__openclaw__/workspace-icon/...

Every one of those URLs returns 200 from curl with the correct Basic credentials, so the gateway and the credentials were never the problem.

Note this is not the <link rel="manifest"> issue diagnosed in the #65 thread. That fix has already shipped (the UI serves crossorigin="use-credentials"), and the prompt persists without this change.

This is the same bug as #37, fixed the same way

#37 was "Webhook Authorization header overwritten by nginx proxy", and #38 fixed it by skipping basic auth for the hook path and passing the client's token through for OpenClaw to validate, which is why HOOKS_PATH carries proxy_set_header Authorization $http_authorization. This applies that same treatment to the UI's own gateway routes.

What changed

scripts/entrypoint.sh only. When AUTH_PASSWORD is set, the generated nginx config gains four maps so that:

  • auth_basic resolves to "off" only for a Bearer-carrying request to a route the gateway itself authenticates: /api/, /__openclaw__/, /rpc, /control-ui-config.json
  • those requests carry the client's token upstream ($http_authorization) instead of the injected Bearer ${GATEWAY_TOKEN}, so the gateway validates it
  • the injected token= query parameter is suppressed on exactly those requests

Two deliberate constraints, both load-bearing:

  1. / is not in the route list. The gateway serves the UI shell unauthenticated, so exempting / would let any bogus Bearer walk straight past basic auth. An earlier draft of this patch did exactly that and returned 200 with the UI to Authorization: Bearer bogus.
  2. The query-parameter injection must be suppressed too. nginx appends token=${GATEWAY_TOKEN} to every proxied request, so relaxing only the header would have handed out fully authenticated access rather than delegating validation.

/browser/ keeps unconditional basic auth: it is not a gateway route, so the realm always resolves to "Openclaw".

Validation

  • With AUTH_PASSWORD unset the generated config is byte-identical to before (generated both ways from the real entrypoint code and diffed; empty).
  • nginx -t passes on the generated config.
  • node scripts/utils.test.js: 81 passed, 0 failed.
  • node --check scripts/configure.js, node --check scripts/utils.js, bash -n scripts/entrypoint.sh.
  • git diff --check clean.

End-to-end against a stub gateway that accepts the token via Bearer or ?token= and rejects anything else. The response body shows which layer rejected:

Request Status Body
no creds, / 401 nginx basic auth
no creds, /api/x 401 nginx basic auth
bogus Bearer, / 401 nginx basic auth
bogus Bearer, /browser/ 401 nginx basic auth
bogus Bearer, /api/x 401 STUB-GATEWAY-REJECTED
bogus Bearer, /control-ui-config.json 401 STUB-GATEWAY-REJECTED
correct Basic, / 200 STUB-GATEWAY-OK
correct Basic, /api/x 200 STUB-GATEWAY-OK
valid gateway Bearer, /api/x 200 STUB-GATEWAY-OK
valid gateway Bearer, /__openclaw__/y 200 STUB-GATEWAY-OK
valid gateway Bearer, /control-ui-config.json 200 STUB-GATEWAY-OK

Also running in production on a fleet of 9 agents as an equivalent out-of-tree patch: Bearer 401s went from 134 to 0 in browser traffic, with 158 Basic 200/304 alongside and the prompt gone.

Open question for maintainers

The four route prefixes are hardcoded, mirroring how HOOKS_PATH is handled. Happy to make them configurable if you would prefer that.

Fixes #65
Closes #66

Narrows and verifies @codezz's nginx fix so the Control UI can send Authorization: Bearer without making the browser re-prompt for basic auth.

Root cause

The Control UI authenticates to the gateway with Authorization: Bearer <token> (per-device token from the WebSocket handshake, then the stored gateway token, then the password). A request has only one Authorization header, so that Bearer replaces the browser's Basic credentials. auth_basic cannot parse it, returns 401 + WWW-Authenticate: Basic, and the browser prompts again. Entering the correct password cannot stop it.

This is the same class of bug as #37, fixed the same way as #38: skip basic auth for those routes and let the gateway validate the client's token. nginx must not inject its own Bearer header or token= query parameter on those requests.

What changed

scripts/entrypoint.sh, only when AUTH_PASSWORD is set. When it is unset, the generated nginx config is byte-identical to main.

Basic auth is skipped only when the header is Bearer (including an empty token) and the path is one of the exact routes below. Every regex is anchored. /, the WebSocket, and /browser/ stay on basic auth. /rpc is not bypassed (gateway RPC is the WebSocket; an unanchored ^/rpc match would also exempt /rpcanything).

scripts/configure.js sets gateway.trustedProxies to 127.0.0.1 and ::1 when the key is not already set. nginx overwrites X-Forwarded-For with $remote_addr so OpenClaw's auth rate limit sees nginx's TCP peer instead of treating every client as loopback (loopback is exempt from lockout). This does not switch gateway.auth.mode; token auth stays in place. A custom trustedProxies list is left alone.

Routes

Discovered against a real OpenClaw 2026.10.5 gateway (Control UI source plus Playwright). The UI, once paired, called the first four with Bearer. The rest are the other Bearer fetches in that build (chat media, avatars, workspace and plugin icons, browser/assistant media). A fresh session has no chat attachment, so those were checked with the same Authorization: Bearer header the UI sends.

Path Why it is bypassed
/control-ui-config.json Bootstrap config. UI GET/HEAD.
/__openclaw__/control-ui-config.json Same document when the UI infers the /__openclaw__ base path.
/api/users/<id>/avatar Profile avatar. UI requested /api/users/gateway-owner/avatar.
/avatar/<id> Agent avatar.
/__openclaw__/assistant-media Chat media and browser screenshot bytes.
/__openclaw__/workspace-icon/<id> Workspace icon.
/__openclaw__/plugin-icon/<id> Plugin icon. UI loaded these.
/__openclaw__/plugin-activity-icon/<id> Plugin activity icon.
/__openclaw__/catalog-icon/<encoded> Catalog icon. UI loaded these.
/__openclaw__/link-favicon/<host> Chat link favicon.
/__openclaw__/channel-avatar/<id> Conversation avatar.
/__openclaw__/plugin-theme-art/<a>/<b>/<c>/<d> Plugin theme art.
/__openclaw__/plugins/control-ui/<id>/<64-hex>/<file> Authenticated plugin UI assets.
/api/chat/media/outgoing/<session>/<uuid>/full|thumbnail Outgoing chat media.

Anything else, including /, /rpc, /rpcanything, /api/<other>, and /__openclaw__/<other>, still requires basic auth.

Verification

Image built from the current Dockerfiles with OPENCLAW_GIT_REF=v2026.10.5 (the separate pnpm ui:build layer hits EXDEV on overlayfs; the test build deleted dist/control-ui in that layer and rebuilt it. Dockerfile.base is not changed here). Ran with AUTH_PASSWORD and OPENCLAW_GATEWAY_TOKEN set.

Playwright logged in with basic auth, submitted the gateway token, and a pending device was approved with openclaw devices approve. On current 2026.10.5 that pairing step happens for a non-loopback client; it is not introduced by the bearer bypass.

Browser, before (main nginx, trusted proxies only so the page can load)

Bearer requests were rejected by nginx (401 + WWW-Authenticate: Basic). The UI then failed to stay on the page.

Request Status Who rejected
GET /api/users/gateway-owner/avatar Bearer 401 nginx basic
GET /control-ui-config.json Bearer 401 nginx basic
HEAD /control-ui-config.json Bearer 401 nginx basic

Unpatched UI after the Bearer 401s

Without gateway.trustedProxies, the same main image returns 403 proxy_attribution_required for an authenticated GET /, because nginx already sends X-Forwarded-* and OpenClaw 2026.10.5 rejects unattributable proxy traffic.

Browser, after (this patch)

248 responses, 164 of them Bearer, zero 401s. The session connected and Plugins / Agents rendered.

Request Status Who answered
GET /control-ui-config.json Bearer 200 gateway
GET /api/users/gateway-owner/avatar Bearer 404 gateway (no avatar bytes; not an auth challenge)
GET /__openclaw__/plugin-icon/<id> Bearer 200 gateway
GET /__openclaw__/catalog-icon/<encoded> Bearer 404 gateway (icon missing upstream)

Patched Control UI connected on the chat page
Patched Control UI agents page

Invalid and empty Bearer

For each bypassed path, Authorization: Bearer not-a-token and Authorization: Bearer (empty) were answered by the gateway with 401 {"type":"unauthorized"} and no WWW-Authenticate: Basic. A following valid gateway token got 200 or 404, never the UI shell and never nginx. After enough failures the gateway returned 429 rate_limited with Retry-After: 300 for that client. Gateway logs show forwardedFor=172.17.0.1 (the Docker bridge peer) while remoteAddr=127.0.0.1. A client-supplied X-Forwarded-For: 8.8.8.8 did not create a separate rate-limit identity.

Request Status Who rejected
no creds, / 401 nginx basic
no creds, /control-ui-config.json 401 nginx basic
bogus Bearer, / 401 nginx basic
bogus Bearer, /browser/ 401 nginx basic
bogus Bearer, /rpc and /rpcanything 401 nginx basic
bogus Bearer, /api/not-a-route 401 nginx basic
bogus Bearer, /__openclaw__/not-a-route 401 nginx basic
bogus or empty Bearer, each bypassed path 401 gateway
valid gateway Bearer, bypassed path 200 or 404 gateway
correct Basic, / 200 UI shell
correct Basic, /browser/ 502 nginx reached the sidecar (no browser container in this run)

node scripts/nginx-bearer.test.js (55) and node scripts/utils.test.js (81) pass. The nginx test checks the route regexes are anchored and that AUTH_PASSWORD unset matches main byte for byte.

Remaining risk

Docker port publishing hides the real client. nginx's peer is usually one bridge address, so the gateway rate-limits that shared address, not each visitor. A burst of bad tokens can lock that address for the whole host. Behind another proxy (Coolify's proxy), the peer is that proxy, same limitation, unless an operator sets a broader gateway.trustedProxies list themselves. This change does not try to trust private ranges automatically, because on Docker that would let any client spoof X-Forwarded-For.

Open in Web Open in Cursor 

The Control UI authenticates to the gateway with `Authorization: Bearer
<token>`. A request carries only one Authorization header, so on every fetch
the UI makes itself, that Bearer replaces the browser's Basic credentials.
auth_basic cannot parse it, returns 401 + WWW-Authenticate, and the browser
prompts again. Entering the correct password cannot help: the next UI fetch
overwrites the header again.

This is the same class of bug as coollabsio#37 (webhook Authorization header overwritten
by the nginx proxy), and it is fixed the same way PR coollabsio#38 fixed that one: skip
basic auth for the affected routes and pass the client's token through so the
gateway validates it.

Scoped to routes the gateway itself authenticates (/api/, /__openclaw__/, /rpc,
/control-ui-config.json). "/" is deliberately excluded: the gateway serves the
UI shell unauthenticated, so exempting it would let any bogus Bearer past basic
auth. The nginx-injected `token=` query parameter is suppressed on passthrough
requests for the same reason, otherwise the carve-out would hand out
authenticated access instead of delegating it.

When AUTH_PASSWORD is unset the generated config is byte-identical to before.

Refs coollabsio#65
@codezz

codezz commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Spent a few days chasing HTTP Auth issue. I kept getting the user/pass prompt infinitely when loading the OpenClaw UI.

The Control UI calls the gateway with Authorization: Bearer , and a request only gets one Authorization header, so every fetch the UI makes overwrites the Basic credentials the browser sent. nginx can't read that as Basic, 401s, and you get prompted again.

That's why the right password never sticks. It works for whatever the browser fetches on its own (page, fonts, WebSocket), then the app makes one call and you're back at the login box. Feels random because it mostly fires on avatars and workspace icons.

@matjaz's manifest crossorigin tip was right and has already shipped, so this is the part that was left.

One thing worth flagging: deleting AUTH_USERNAME / AUTH_PASSWORD does stop the prompt, but nginx injects the gateway token on every proxied request, so it leaves the Control UI with no auth in front of it at all. Fine on a private network, less so on a public box.

Skip basic auth only for the exact paths the Control UI fetches with
Authorization: Bearer, and forward the client's header instead of
injecting the gateway token. Anchor every regex. Keep /, the WebSocket,
and /browser/ on basic auth, and leave the generated config unchanged
when AUTH_PASSWORD is unset.

Trust the loopback nginx hop via gateway.trustedProxies and overwrite
X-Forwarded-For with the TCP peer so auth rate limits are not stuck on
127.0.0.1.

Co-authored-by: Andras Bacsai <andrasbacsai@users.noreply.github.com>
@gitguardian

gitguardian Bot commented Oct 19, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

Since your pull request originates from a forked repository, GitGuardian is not able to associate the secrets uncovered with secret incidents on your GitGuardian dashboard.
Skipping this check run and merging your pull request will create secret incidents on your GitGuardian dashboard.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
36526964 Triggered Generic Password dcaa92b scripts/nginx-bearer.test.js View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@andrasbacsai
andrasbacsai merged commit 7d742c6 into coollabsio:main Oct 19, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Docker cannot login after upgrade to 2026.4.21 or higher Nginx Basic Auth Re-prompts Continuously After Login (Regression in Latest Versions)

3 participants