Skip to content

Trusted proxy auth

**Security-sensitive feature.** This mode delegates authentication entirely to your reverse proxy. Misconfiguration can expose your Gateway to unauthorized access. Read this page carefully before enabling.

When to use

Use trusted-proxy auth mode when:

  • You run RemoteClaw behind an identity-aware proxy (Pomerium, Caddy + OAuth, nginx + oauth2-proxy, Traefik + forward auth).
  • Your proxy handles all authentication and passes user identity via headers.
  • You’re in a Kubernetes or container environment where the proxy is the only path to the Gateway.
  • You’re hitting WebSocket 1008 unauthorized errors because browsers can’t pass tokens in WS payloads.

When NOT to use

  • If your proxy doesn’t authenticate users (just a TLS terminator or load balancer).
  • If there’s any path to the Gateway that bypasses the proxy (firewall holes, internal network access).
  • If you’re unsure whether your proxy correctly strips/overwrites forwarded headers.
  • If you only need personal single-user access (consider Tailscale Serve + loopback for simpler setup).

How it works

Your reverse proxy authenticates users (OAuth, OIDC, SAML, etc.). Proxy adds a header with the authenticated user identity (e.g., `x-forwarded-user: nick@example.com`). RemoteClaw checks that the request came from a **trusted proxy IP** (configured in `gateway.trustedProxies`). RemoteClaw extracts the user identity from the configured header. If everything checks out, the request is authorized.

Control UI pairing behavior

When gateway.auth.mode = "trusted-proxy" is active, device pairing is still required for Control UI WebSocket sessions that present a device identity. Passing trusted-proxy checks does not skip the pairing gate.

The two are answering different questions, and only one of them is delegated to your proxy:

  • Trusted-proxy auth establishes who the user is.
  • Device pairing establishes which device is speaking for that user.

Implications:

  • Your reverse proxy auth policy and allowUsers gate who may reach the Gateway; pairing remains the gate on what device may act.
  • Keep gateway ingress locked to trusted proxy IPs only (gateway.trustedProxies + firewall).
  • A trusted-proxy Control UI session is not issued a device token. Device tokens are bearer credentials that would outlive the proxy-fronted session and would authorize a direct connection that never traverses the proxy.

Two cases behave differently, and the difference is deliberate:

A device identity that is not paired is rejected. The connect fails with pairing required (detail code PAIRING_REQUIRED) and a pairing request is raised for approval, exactly as on any other transport. Previously such a device was admitted on the strength of proxy auth alone and received the scopes it had declared for itself, without ever having been paired.

Scope clearing without device identity: Because the browser over plain HTTP cannot create the device identity that RemoteClaw uses to bind operator scopes, trusted-proxy WebSocket connections that lack device identity entirely still connect, but have their self-declared scopes cleared to an empty set. Scope-gated methods (operator.read, operator.write, etc.) then fail with missing scope.

To preserve operator scopes on trusted-proxy WebSocket connections without device identity — and to opt out of the pairing requirement above — set gateway.controlUi.dangerouslyDisableDeviceAuth: true. This is a break-glass flag (remoteclaw security audit reports it as critical). Use it only when the reverse proxy is the sole path to the Gateway and device identity cannot be established.

Configuration

{
gateway: {
// Trusted-proxy auth requires a proxy source address the Gateway does not itself hold
bind: "lan",
// CRITICAL: Only add your proxy's IP(s) here — the address the proxy connects FROM,
// which is not the Gateway's own bridge/LAN address. 172.17.0.1 is correct when the
// Gateway runs in a container and the proxy sits on the Docker host.
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: ["nick@example.com", "admin@company.org"],
},
},
},
}
**Important runtime rules**
  • The proxy must connect from an address the Gateway does not itself hold. Trusted-proxy auth rejects the connecting (peer) address when it is loopback (127.0.0.1, ::1, loopback CIDRs), or when it matches any interface address the Gateway can enumerate — so the Gateway host cannot impersonate an upstream proxy. It also fails closed if those interfaces cannot be enumerated at all. There is no opt-in override for any of this.
  • This rules out a proxy that shares the Gateway’s network namespace — an ordinary same-host process reaching the Gateway over loopback or over one of its own interface addresses, or a container started with --network=host. No configuration makes those work; use gateway.auth.password for those callers. A proxy with its own network namespace is fine even on the same machine: a separate host, or a container/VM reaching the Gateway across a bridge, connects from an address the Gateway does not hold. List that source address in gateway.trustedProxies.
  • Internal Gateway clients that do not travel through the reverse proxy should use gateway.auth.password / REMOTECLAW_GATEWAY_PASSWORD, not trusted-proxy identity headers.
  • Non-loopback Control UI deployments still need explicit gateway.controlUi.allowedOrigins.
  • Forwarded-header evidence overrides loopback locality for local direct fallback. If a request arrives on loopback but carries Forwarded, any X-Forwarded-*, or X-Real-IP header evidence, that evidence disqualifies local-direct password fallback and device-identity gating. Such a request is not rescued by trusted-proxy auth either, since its loopback source is rejected outright.

Configuration reference

Array of proxy IP addresses to trust. Requests from other IPs are rejected. Must be `"trusted-proxy"`. Header name containing the authenticated user identity. Additional headers that must be present for the request to be trusted. Allowlist of user identities. Empty means allow all authenticated users. There is no escape hatch for a proxy that shares the Gateway's network namespace. Trusted-proxy auth deliberately offers no option to accept a loopback or own-interface peer address: any local process that can connect to the Gateway could otherwise send proxy identity headers and impersonate an authenticated user. Run the proxy where it reaches the Gateway from an address the Gateway does not hold, and use `gateway.auth.password` for callers that cannot.

TLS termination and HSTS

Use one TLS termination point and apply HSTS there.

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.
- RemoteClaw can stay on loopback HTTP behind the proxy.
Example header value:
```text
Strict-Transport-Security: max-age=31536000; includeSubDomains
```
If RemoteClaw itself serves HTTPS directly (no TLS-terminating proxy), set:
```json5
{
gateway: {
tls: { enabled: true },
http: {
securityHeaders: {
strictTransportSecurity: "max-age=31536000; includeSubDomains",
},
},
},
}
```
`strictTransportSecurity` accepts a string header value, or `false` to disable explicitly.

Rollout guidance

  • Start with a short max age first (for example max-age=300) while validating traffic.
  • Increase to long-lived values (for example max-age=31536000) only after confidence is high.
  • Add includeSubDomains only if every subdomain is HTTPS-ready.
  • Use preload only if you intentionally meet preload requirements for your full domain set.
  • Loopback-only local development does not benefit from HSTS.

Proxy setup examples

Pomerium passes identity in `x-pomerium-claim-email` (or other claim headers) and a JWT in `x-pomerium-jwt-assertion`.
```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://remoteclaw.example.com
to: http://remoteclaw-gateway:18789
policy:
- allow:
or:
- email:
is: nick@example.com
pass_identity_headers: true
```
Caddy with the `caddy-security` plugin can authenticate users and pass identity headers.
```json5
{
gateway: {
bind: "lan",
trustedProxies: ["10.0.0.1"], // Caddy/sidecar proxy IP
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
},
}
```
Caddyfile snippet:
```
remoteclaw.example.com {
authenticate with oauth2_provider
authorize with policy1
reverse_proxy remoteclaw:18789 {
header_up X-Forwarded-User {http.auth.user.email}
}
}
```
oauth2-proxy authenticates users and passes identity in `x-auth-request-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://remoteclaw: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";
}
```
```json5 { gateway: { bind: "lan", trustedProxies: ["172.17.0.1"], // Traefik container IP auth: { mode: "trusted-proxy", trustedProxy: { userHeader: "x-forwarded-user", }, }, }, } ```

Mixed token configuration

RemoteClaw rejects ambiguous configurations where both a gateway.auth.token (or REMOTECLAW_GATEWAY_TOKEN) and trusted-proxy mode are active at the same time. Mixed token configs can cause loopback requests to silently authenticate on the wrong auth path.

If you see a mixed_trusted_proxy_token error on startup:

  • Remove the shared token when using trusted-proxy mode, or
  • Switch 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 RemoteClaw callers that bypass the proxy may authenticate with gateway.auth.password / REMOTECLAW_GATEWAY_PASSWORD instead. Token fallback remains intentionally unsupported in trusted-proxy mode.

Operator scopes header

Trusted-proxy auth is an identity-bearing HTTP mode, so callers may optionally declare operator scopes with x-remoteclaw-scopes.

Note: x-remoteclaw-scopes applies to HTTP endpoints only. WebSocket scopes are determined by the Gateway protocol handshake and device identity binding. For WebSocket scope behavior with trusted-proxy, see Control UI pairing behavior.

Examples:

  • x-remoteclaw-scopes: operator.read
  • x-remoteclaw-scopes: operator.read,operator.write
  • x-remoteclaw-scopes: operator.admin,operator.write

Behavior:

  • When the header is present, RemoteClaw honors the declared scope set.
  • When the header is present but empty, the request declares no operator scopes.
  • When the header is absent, normal identity-bearing HTTP APIs fall back to the standard operator default scope set.
  • Gateway-auth plugin HTTP routes are narrower by default: when x-remoteclaw-scopes is absent, their runtime scope falls back to operator.write.
  • Browser-origin HTTP requests still have to pass gateway.controlUi.allowedOrigins (or deliberate Host-header fallback mode) even after trusted-proxy auth succeeds.

Practical rule: send x-remoteclaw-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.

Security checklist

Before enabling trusted-proxy auth, verify:

  • Proxy is the only path: The Gateway port is firewalled from everything except your proxy.
  • trustedProxies is minimal: Only your actual proxy IPs, not entire subnets.
  • Proxy source is an address the Gateway does not hold: trusted-proxy auth fails closed for loopback and own-interface peer addresses, with no override. A proxy sharing the Gateway’s network namespace cannot be made to work; use gateway.auth.password for those callers.
  • Proxy strips headers: Your proxy overwrites (not appends) x-forwarded-* headers from clients.
  • TLS termination: Your proxy handles TLS; users connect via HTTPS.
  • allowedOrigins is explicit: Non-loopback Control UI uses explicit gateway.controlUi.allowedOrigins.
  • allowUsers is set (recommended): Restrict to known users rather than allowing anyone authenticated.
  • No mixed token config: Do not set both gateway.auth.token and gateway.auth.mode: "trusted-proxy".
  • Local password fallback is private: If you configure gateway.auth.password for internal direct callers, keep the Gateway port firewalled so non-proxy remote clients cannot reach it directly.

Security audit

remoteclaw security audit will flag 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:

  • Base gateway.trusted_proxy_auth warning/critical reminder
  • Missing trustedProxies configuration
  • Missing userHeader configuration
  • Empty allowUsers (allows any authenticated user)
  • Wildcard or missing browser-origin policy on exposed Control UI surfaces

Troubleshooting

The request didn't come from an IP in `gateway.trustedProxies`. Check:
- 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.
RemoteClaw rejected a loopback-source trusted-proxy request.
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
- Move the proxy so it connects from an address the Gateway does not hold, and keep that IP in `gateway.trustedProxies`.
There is no opt-in. A reverse proxy that reaches the Gateway over loopback cannot satisfy trusted-proxy auth in any configuration — the rejection is unconditional, so this is a deployment change, not a setting to flip.
Do not simply retarget the proxy at the host's own LAN address: that trades this error for `trusted_proxy_local_interface_source` below.
The peer address was not loopback, but it matched one of the Gateway's own interface addresses — so the Gateway would have been trusting itself as an upstream proxy.
This is what you get when a same-host proxy is pointed at the host's LAN or bridge address instead of `127.0.0.1`. It is the same restriction as `trusted_proxy_loopback_source`, not a separate one to work around.
Fix:
- Run the proxy where it has its own network namespace — a separate host, or a container/VM reaching the Gateway across a bridge — so its peer address is one the Gateway does not hold, and list that address in `gateway.trustedProxies`, or
- Use `gateway.auth.password` for callers that must stay in the Gateway's own namespace.
A related reason code, `trusted_proxy_local_interface_check_failed`, means the Gateway could not enumerate its interfaces at all and failed closed.
The user header was empty or missing. Check:
- 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?
A required header wasn't present. Check:
- Your proxy configuration for those specific headers.
- Whether headers are being stripped somewhere in the chain.
The user is authenticated but not in `allowUsers`. Either add them or remove the allowlist. Trusted-proxy auth succeeded, but the browser `Origin` header did not pass Control UI origin checks.
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.
The WebSocket connects, but `chat.history` or `sessions.list` fails with `missing scope: operator.read`.
This is expected for trusted-proxy WebSocket connections without device
identity. Connections lacking device identity have their scopes cleared. The
browser cannot generate device identity over plain HTTP.
Fix:
- Set `gateway.controlUi.dangerouslyDisableDeviceAuth: true` to preserve operator scopes on trusted-proxy WebSocket connections, or
- Use device identity pairing so scopes are bound to the device token.
Make sure your proxy:
- 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.

Migration from token auth

If you’re moving from token auth to trusted-proxy:

Configure your proxy to authenticate users and pass headers. Test the proxy setup independently (curl with headers). Update RemoteClaw config with trusted-proxy auth. Restart the Gateway. Test WebSocket connections from the Control UI. Run `remoteclaw security audit` and review findings.