Skip to content

feat(gateway): server-side token injection for reverse proxy deployments#10382

Closed
nkuhn-vmw wants to merge 3 commits intoopenclaw:mainfrom
nkuhn-vmw:feat/reverse-proxy-token-injection
Closed

feat(gateway): server-side token injection for reverse proxy deployments#10382
nkuhn-vmw wants to merge 3 commits intoopenclaw:mainfrom
nkuhn-vmw:feat/reverse-proxy-token-injection

Conversation

@nkuhn-vmw
Copy link
Copy Markdown

@nkuhn-vmw nkuhn-vmw commented Feb 6, 2026

Summary

When running OpenClaw behind a reverse proxy (e.g. oauth2-proxy for SSO), the browser needs the gateway token for WebSocket authentication. Currently this requires deploying a custom HTML injection middleware between the SSO proxy and OpenClaw — adding operational complexity and an extra process to manage.

This PR adds a native gateway.auth.injectTokenFromHeader config option that:

  • Reads the gateway auth token from an HTTP request header (set by the reverse proxy)
  • Passes it to the browser via the bootstrap JSON config endpoint (/__openclaw/control-ui-config.json)
  • The Control UI stores it in localStorage and uses it for WebSocket authentication

This eliminates the need for custom token injection proxies in SSO deployments.

Motivation

In Cloud Foundry (and similar PaaS) deployments, oauth2-proxy handles SSO authentication as a sidecar process. The gateway token must reach the browser for WebSocket auth, but:

  • The token can't go in HTTP headers (WS auth is at protocol level, not HTTP upgrade)
  • The ?token= query param approach doesn't work with SSO redirects (proxy can't inject query params)
  • The current workaround requires a separate Node.js proxy process that intercepts HTML responses and injects a <script> tag — this is fragile and adds ~50 lines of deployment glue

With this change, the reverse proxy just sets an HTTP header (e.g. --pass-header=X-OpenClaw-Token in oauth2-proxy), and the gateway handles the rest natively.

Configuration

{
  "gateway": {
    "auth": {
      "mode": "token",
      "token": "my-secret",
      "injectTokenFromHeader": {
        "enabled": true,
        "headerName": "x-openclaw-token"
      }
    }
  }
}

Default header name is x-openclaw-token. The feature is off by default — no behavior change for existing deployments.

Changes

  • src/config/types.gateway.ts — Add GatewayTokenInjectionConfig type to GatewayAuthConfig
  • src/config/schema.labels.ts / src/config/schema.help.ts — Add UI labels and help text for the new config fields
  • src/gateway/control-ui-contract.ts — Add optional token field to ControlUiBootstrapConfig
  • src/gateway/control-ui.ts — Extract token from request header in handleControlUiHttpRequest(), include it in the bootstrap JSON config response
  • ui/src/ui/controllers/control-ui-bootstrap.ts — Return token from bootstrap config fetch
  • ui/src/ui/app-lifecycle.ts — When no token is present, await bootstrap config (which may provide a reverse-proxy token) before connecting the WebSocket

Architecture

Token delivery uses the existing bootstrap JSON config endpoint rather than inline script injection, which is compatible with the Control UI's CSP policy (script-src 'self' without 'unsafe-inline').

Token precedence (highest to lowest):

  1. ?token= query param (client-side, via applySettingsFromUrl()) — always wins
  2. Existing localStorage token — preserved from previous sessions
  3. Header injection via bootstrap JSON (this PR) — seeds token when none exists

Startup flow:

  • If the user already has a token (from localStorage or ?token=), the WS connects immediately; bootstrap config fetches in the background
  • If no token exists, the client awaits the bootstrap config fetch before connecting, so any server-injected token is available for WS auth

Security Considerations

  • Token injection only occurs when explicitly enabled via config (enabled: true)
  • The header is only read from requests that reach the gateway (behind the reverse proxy)
  • Combined with gateway.trustedProxies, only requests from trusted IPs are accepted
  • Uses getHeader() which handles both string and string[] header values (for proxies that emit duplicate headers)
  • No inline scripts — token is delivered via JSON endpoint, respecting the existing CSP policy
  • No changes to the WebSocket auth protocol

Test plan

  • Verify existing ?token= query param flow still works unchanged
  • Verify injectTokenFromHeader.enabled: false (default) has no effect
  • Verify token injection when enabled: true and header is present
  • Verify no injection when header is absent (graceful no-op)
  • Verify header name is case-insensitive (HTTP spec compliance)
  • CI passes (node, bun, windows tests all green)

🤖 Generated with Claude Code

@openclaw-barnacle openclaw-barnacle Bot added app: web-ui App: web-ui gateway Gateway runtime labels Feb 6, 2026
Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 file reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment thread src/gateway/control-ui.ts
Comment on lines +272 to +280
// Extract token from reverse proxy header when configured.
const injectionConfig = opts?.config?.gateway?.auth?.injectTokenFromHeader;
let injectedToken: string | undefined;
if (injectionConfig?.enabled) {
const headerName = (injectionConfig.headerName ?? "x-openclaw-token").toLowerCase();
const headerValue = req.headers[headerName];
if (typeof headerValue === "string" && headerValue.trim()) {
injectedToken = headerValue.trim();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple header values ignored

req.headers[headerName] can be a string[] when the proxy sends the header multiple times; in that case injectedToken stays undefined and token injection silently won’t happen even though the header is present. The codebase already has getHeader() which handles string | string[] and would make this robust. This affects reverse proxies that emit duplicate headers (or combine) during redirects / auth flows.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/control-ui.ts
Line: 272:280

Comment:
**Multiple header values ignored**

`req.headers[headerName]` can be a `string[]` when the proxy sends the header multiple times; in that case `injectedToken` stays `undefined` and token injection silently won’t happen even though the header is present. The codebase already has `getHeader()` which handles `string | string[]` and would make this robust. This affects reverse proxies that emit duplicate headers (or combine) during redirects / auth flows.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed in 673869d — now uses the existing getHeader() from src/gateway/http-utils.ts which handles both string and string[] header values (takes the first element for arrays). This covers proxies that emit duplicate headers during auth flows.

Comment thread src/gateway/control-ui.ts Outdated
Comment on lines +176 to +183
const tokenSnippet = token
? `(function(){try{` +
`var k="openclaw.control.settings.v1",s={};` +
`try{s=JSON.parse(localStorage.getItem(k)||"{}")}catch(e){}` +
`s.token=${JSON.stringify(token)};` +
`localStorage.setItem(k,JSON.stringify(s));` +
`}catch(e){}})();`
: "";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ambiguous token precedence

The server-side injection always writes the token into localStorage when the header is present. If the same page load also uses the existing URL-based token import (query params), the two mechanisms can conflict and the header-provided token will overwrite the other value. It would be better to define a deterministic precedence rule (e.g., skip injection when a token is already set, or skip when a token query param is present) to avoid surprising behavior during migration/troubleshooting.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/control-ui.ts
Line: 176:183

Comment:
**Ambiguous token precedence**

The server-side injection always writes the token into localStorage when the header is present. If the same page load also uses the existing URL-based token import (query params), the two mechanisms can conflict and the header-provided token will overwrite the other value. It would be better to define a deterministic precedence rule (e.g., skip injection when a token is already set, or skip when a token query param is present) to avoid surprising behavior during migration/troubleshooting.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Fixed in 673869d — the injection snippet now only sets the token when localStorage doesn't already have one (if(!s.token){...}). This gives a deterministic precedence:

  1. ?token= query param (client-side, via applySettingsFromUrl()) — wins, because it runs after page load and overwrites localStorage
  2. Header injection (server-side, this PR) — only seeds localStorage if empty, acts as a default
  3. Existing localStorage — preserved if already set by either mechanism

So during migration or troubleshooting, explicitly passing ?token=newvalue in the URL will always take effect regardless of header injection.

@openclaw-barnacle
Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Feb 21, 2026
tehkuhnz and others added 2 commits February 21, 2026 09:27
When running OpenClaw behind a reverse proxy (e.g. oauth2-proxy for SSO),
the browser needs the gateway token for WebSocket auth. Currently this
requires a custom HTML injection proxy between the SSO proxy and OpenClaw.

This adds a `gateway.auth.injectTokenFromHeader` config option that reads
the gateway token from an HTTP request header (set by the reverse proxy)
and injects it into the Control UI HTML via a <script> tag that populates
localStorage. This eliminates the need for a custom token injection proxy.

Config example:
```json
{
  "gateway": {
    "auth": {
      "mode": "token",
      "token": "my-secret",
      "injectTokenFromHeader": {
        "enabled": true,
        "headerName": "x-openclaw-token"
      }
    }
  }
}
```

The reverse proxy sets the header (e.g. oauth2-proxy --pass-header), and
the gateway auto-populates the browser's localStorage with the token on
every HTML page load. Existing ?token= query param flow is unchanged.

Changes:
- types.gateway.ts: Add GatewayTokenInjectionConfig type
- control-ui.ts: Extract token from header, inject into HTML via localStorage
- schema.ts: Add UI hints and help text for new config fields
Address review feedback:
1. Use getHeader() from http-utils.ts instead of raw req.headers[]
   to correctly handle string[] when proxies send duplicate headers.
2. Only inject token into localStorage when no token is already set,
   so ?token= query param (client-side) takes precedence over
   header injection (server-side).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@nkuhn-vmw nkuhn-vmw force-pushed the feat/reverse-proxy-token-injection branch from 673869d to 40d372f Compare February 21, 2026 14:28
…inline scripts

Adapt to main's CSP-safe architecture that disallows inline scripts.
Instead of injecting a <script> tag into index.html, pass the token
through the existing bootstrap JSON config endpoint. The client
awaits the config before connecting when no token is present.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@nkuhn-vmw
Copy link
Copy Markdown
Author

Closing this PR — the trusted-proxy auth mode added in main (Feb 14) covers the reverse proxy/SSO use case more cleanly:

{
  "gateway": {
    "auth": {
      "mode": "trusted-proxy",
      "trustedProxy": {
        "userHeader": "x-auth-request-email"
      }
    },
    "trustedProxies": ["10.0.0.1"]
  }
}

trusted-proxy validates identity headers directly on both HTTP and WebSocket upgrade requests, so there's no need to inject a token into the browser at all. It also provides per-user identity rather than a shared secret, which is a better fit for SSO deployments.

@nkuhn-vmw nkuhn-vmw closed this Feb 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: web-ui App: web-ui gateway Gateway runtime size: S stale Marked as stale due to inactivity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant