docs/gateway/trusted-proxy-auth.md
1008 unauthorized errors because browsers can't pass tokens in WS payloads.{
gateway: {
// Trusted-proxy auth expects the proxy's source IP to be non-loopback by default
bind: "lan",
// CRITICAL: Only add your proxy's IP(s) here
trustedProxies: ["10.0.0.1", "172.17.0.1"],
auth: {
mode: "trusted-proxy",
trustedProxy: {
// Header containing authenticated user identity (required)
userHeader: "x-forwarded-user",
// Optional: headers that MUST be present (proxy verification)
requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"],
// Optional: restrict to specific users (empty = allow all)
allowUsers: ["[email protected]", "[email protected]"],
// Optional: allow a same-host loopback proxy after explicit opt-in
allowLoopback: false,
},
},
},
}
gateway.trustedProxies (CIDR-aware), or it is rejected (trusted_proxy_untrusted_source).127.0.0.1, ::1) are rejected unless gateway.auth.trustedProxy.allowLoopback = true and the loopback address is also in trustedProxies (trusted_proxy_loopback_source). This check runs before header checks, so a loopback source fails this way even if required headers are also missing.trusted_proxy_local_interface_source). If interface discovery itself fails, the request is rejected too (trusted_proxy_local_interface_check_failed).requiredHeaders and userHeader must be present and non-blank.allowUsers, if non-empty, must include the extracted user.Forwarded-header evidence overrides loopback locality for local-direct fallback. If a request arrives on loopback but carries a Forwarded, any X-Forwarded-*, or X-Real-IP header, that evidence disqualifies it from local-direct password fallback and device-identity gating, even though it still fails trusted-proxy auth as loopback.
allowLoopback trusts local processes on the Gateway host to the same degree as the reverse proxy. Enable it only when the Gateway is still firewalled from direct remote access and the local proxy strips or overwrites client-supplied identity headers.
Internal Gateway clients that do not travel through the reverse proxy should use gateway.auth.password / OPENCLAW_GATEWAY_PASSWORD, not trusted-proxy identity headers. Non-loopback Control UI deployments still need explicit gateway.controlUi.allowedOrigins.
</Warning>
When gateway.auth.mode = "trusted-proxy" is active and the request passes trusted-proxy checks, Control UI WebSocket sessions can connect without device pairing identity.
Scope implications:
[] so a session not bound to an approved paired device/token cannot self-declare permissions.missing scope after a successful WebSocket connect, use HTTPS so the browser can generate device identity and complete pairing. See Control UI insecure HTTP.gateway.controlUi.dangerouslyDisableDeviceAuth=true preserves requested scopes even without device identity. This is a severe security downgrade; revert quickly. See Control UI insecure HTTP.Reverse-proxy scope capping: if your proxy sends x-openclaw-scopes on the Control UI WebSocket upgrade request, OpenClaw caps the session scopes to the intersection of the requested scopes and the declared scopes. This header does not grant scopes; it only narrows what the session can hold.
Implications:
allowUsers become the effective access control.gateway.trustedProxies + firewall).Custom WebSocket clients are not Control UI sessions. gateway.controlUi.dangerouslyDisableDeviceAuth does not grant scopes to arbitrary client.mode: "backend" or CLI-shaped clients. Custom automation should use device identity/pairing, the reserved direct-local client.id: "gateway-client" backend helper path, or the admin HTTP RPC plugin when an HTTP request/response surface is a better fit.
Trusted-proxy auth is an identity-bearing HTTP mode, so callers may optionally declare operator scopes with x-openclaw-scopes on HTTP API requests.
Note: WebSocket scopes are determined by the Gateway protocol handshake and device identity binding. On Control UI WebSocket upgrade requests, x-openclaw-scopes is only a cap on the negotiated session scopes, not a grant. See Control UI pairing behavior.
Examples:
x-openclaw-scopes: operator.readx-openclaw-scopes: operator.read,operator.writex-openclaw-scopes: operator.admin,operator.writeBehavior:
operator.admin, operator.read, operator.write, operator.approvals, operator.pairing, operator.talk.secrets).x-openclaw-scopes is absent, their runtime scope falls back to operator.write only.gateway.controlUi.allowedOrigins (or deliberate Host-header fallback mode) even after trusted-proxy auth succeeds.Practical rule: send x-openclaw-scopes explicitly when you want a trusted-proxy request to be narrower than the defaults, or when a gateway-auth plugin route needs something stronger than write scope.
Use one TLS termination point and apply HSTS there.
<Tabs> <Tab title="Proxy TLS termination (recommended)"> When your reverse proxy handles HTTPS for `https://control.example.com`, set `Strict-Transport-Security` at the proxy for that domain.- Good fit for internet-facing deployments.
- Keeps certificate + HTTP hardening policy in one place.
- OpenClaw can stay on loopback HTTP behind the proxy.
Example header value:
```text
Strict-Transport-Security: max-age=31536000; includeSubDomains
```
```json5
{
gateway: {
tls: { enabled: true },
http: {
securityHeaders: {
strictTransportSecurity: "max-age=31536000; includeSubDomains",
},
},
},
}
```
`strictTransportSecurity` accepts a string header value, or `false` to disable explicitly.
max-age=300) while validating traffic.max-age=31536000) only after confidence is high.includeSubDomains only if every subdomain is HTTPS-ready.```json5
{
gateway: {
bind: "lan",
trustedProxies: ["10.0.0.1"], // Pomerium's IP
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-pomerium-claim-email",
requiredHeaders: ["x-pomerium-jwt-assertion"],
},
},
},
}
```
Pomerium config snippet:
```yaml
routes:
- from: https://openclaw.example.com
to: http://openclaw-gateway:18789
policy:
- allow:
or:
- email:
is: [email protected]
pass_identity_headers: true
```
```json5
{
gateway: {
bind: "lan",
trustedProxies: ["10.0.0.1"], // Caddy/sidecar proxy IP
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
},
}
```
Caddyfile snippet:
```caddy
openclaw.example.com {
authenticate with oauth2_provider
authorize with policy1
reverse_proxy openclaw:18789 {
header_up X-Forwarded-User {http.auth.user.email}
}
}
```
```json5
{
gateway: {
bind: "lan",
trustedProxies: ["10.0.0.1"], // nginx/oauth2-proxy IP
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-auth-request-email",
},
},
},
}
```
nginx config snippet:
```nginx
location / {
auth_request /oauth2/auth;
auth_request_set $user $upstream_http_x_auth_request_email;
proxy_pass http://openclaw:18789;
proxy_set_header X-Auth-Request-Email $user;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
Gateway startup rejects trusted-proxy auth if a shared token is also configured (gateway.auth.token or OPENCLAW_GATEWAY_TOKEN). The two are mutually exclusive because a shared token would let same-host callers authenticate on a completely different path than the proxy-verified identity this mode is meant to enforce.
If startup fails with an error like gateway auth mode is trusted-proxy, but a shared token is also configured:
gateway.auth.mode to "token" if you intend token-based auth.Loopback trusted-proxy identity headers still fail closed: same-host callers are not silently authenticated as proxy users. Internal OpenClaw callers that bypass the proxy may authenticate with gateway.auth.password / OPENCLAW_GATEWAY_PASSWORD instead. Token fallback remains intentionally unsupported in trusted-proxy mode.
Before enabling trusted-proxy auth, verify:
gateway.auth.trustedProxy.allowLoopback is explicitly enabled for a same-host proxy.x-forwarded-* headers from clients.gateway.controlUi.allowedOrigins.gateway.auth.token and gateway.auth.mode: "trusted-proxy".gateway.auth.password for internal direct callers, keep the Gateway port firewalled so non-proxy remote clients cannot reach it directly.openclaw security audit flags trusted-proxy auth with a critical severity finding. This is intentional; it's a reminder that you're delegating security to your proxy setup.
The audit checks for:
gateway.trusted_proxy_auth warning/critical reminder.trustedProxies configuration.userHeader configuration.allowUsers (allows any authenticated user).allowLoopback for same-host proxy sources.Separate, non-trusted-proxy-specific findings also apply whenever Control UI is exposed: wildcard or missing gateway.controlUi.allowedOrigins, and Host-header origin fallback.
- Is the proxy IP correct? (Docker container IPs can change.)
- Is there a load balancer in front of your proxy?
- Use `docker inspect` or `kubectl get pods -o wide` to find actual IPs.
Check:
- Is the proxy connecting from `127.0.0.1` / `::1`?
- Are you trying to use trusted-proxy auth with a same-host loopback reverse proxy?
Fix:
- Prefer token/password auth for internal same-host clients that do not go through the proxy, or
- Route through a non-loopback trusted proxy address and keep that IP in `gateway.trustedProxies`, or
- For a deliberate same-host reverse proxy, set `gateway.auth.trustedProxy.allowLoopback = true`, keep the loopback address in `gateway.trustedProxies`, and make sure the proxy strips or overwrites identity headers.
Check:
- Is a process on the Gateway host itself sending identity headers directly, bypassing the proxy?
- Does the proxy run in the same network namespace as the Gateway, with an IP that also shows up as a local interface?
Fix: route proxy traffic through an address that is not also bound locally by the Gateway host, or use `allowLoopback` only for a genuine same-host proxy setup.
- Is your proxy configured to pass identity headers?
- Is the header name correct? (case-insensitive, but spelling matters)
- Is the user actually authenticated at the proxy?
- Your proxy configuration for those specific headers.
- Whether headers are being stripped somewhere in the chain.
Check:
- `gateway.controlUi.allowedOrigins` includes the exact browser origin.
- You are not relying on wildcard origins unless you intentionally want allow-all behavior.
- If you intentionally use Host-header fallback mode, `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set deliberately.
Common causes:
- Device-less Control UI session: trusted-proxy auth can admit the WebSocket connection without device identity, but OpenClaw clears scopes on device-less sessions by design.
- Custom backend client: `gateway.controlUi.dangerouslyDisableDeviceAuth` is Control UI scoped and does not grant scopes to arbitrary backend or CLI-shaped WebSocket clients.
- Overly narrow `x-openclaw-scopes`: if your proxy injects this header on the Control UI WebSocket upgrade request, the session scopes are capped to that set. An empty header value yields no scopes.
Fix:
- For Control UI, use HTTPS so the browser can generate device identity and complete pairing.
- For custom automation, use device identity/pairing, the reserved direct-local `gateway-client` backend helper path, or [admin HTTP RPC](/plugins/admin-http-rpc).
- Use `gateway.controlUi.dangerouslyDisableDeviceAuth: true` only as a temporary Control UI break-glass path.
- Supports WebSocket upgrades (`Upgrade: websocket`, `Connection: upgrade`).
- Passes the identity headers on WebSocket upgrade requests (not just HTTP).
- Doesn't have a separate auth path for WebSocket connections.