The AuthSec OpenClaw Proxy is a reverse proxy that mediates all access to OpenClaw Gateway instances. It authenticates users via an authsec.Client adapter (OIDC or stub for development), authorizes access per tenant using Authorize(), and injects upstream credentials that are never exposed to clients.
- OpenClaw Gateway tokens — static bearer tokens that grant full access to upstream gateways. Compromise allows unauthorized API/WebSocket access.
- User sessions — session cookies that represent authenticated identity.
- Tenant isolation — each tenant's upstream is logically separate; cross-tenant access must be prevented.
| Threat | Mitigation |
|---|---|
| Gateway token leakage via client | Token is injected server-side in the proxy Rewrite hook and never sent to clients. Client Authorization headers are stripped before forwarding. |
| Token leakage via query params | All requests containing token=, gateway_token=, auth=, or access_token= query parameters are rejected with 400 before reaching any handler. |
| Token leakage via logs | The structured logging handler redacts any attribute whose key contains token, secret, password, authorization, cookie, or hmac. |
| Session fixation | Session IDs are 32 cryptographically random bytes, HMAC-SHA256 signed. The HMAC key is a separate server-side secret. |
| Session hijacking | Cookies are HttpOnly (no JavaScript access), Secure (HTTPS only in production), SameSite=Lax (prevents CSRF on GET). |
| CSRF on logout | POST /logout requires a CSRF token (double-submit pattern). The token is bound to the session and validated with constant-time comparison. |
| Brute-force login | /login and /callback endpoints are rate-limited per IP (token bucket, configurable requests/minute). |
| Oversized request bodies | Non-WebSocket requests have a configurable body size limit (default 1MB) enforced via http.MaxBytesReader. |
| X-Forwarded- header spoofing* | The proxy overwrites (never appends) X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host from the actual connection. Client-supplied values are discarded. |
| Unauthorized tenant access | Access requires passing authsec.Client.Authorize() which checks admin status (email/sub), tenant membership, and required claims. |
| Upstream cookie leakage | Set-Cookie headers from the upstream gateway are stripped from responses. Client cookies are stripped from upstream requests. |
All authentication and authorization flows go through the authsec.Client interface:
type Client interface {
ValidateAccessToken(ctx, rawToken) (*Claims, error)
BeginBrowserLogin(ctx, returnToURL) (redirectURL, opaqueState, error)
CompleteBrowserLogin(ctx, params, opaqueState) (*Identity, *TokenSet, error)
Authorize(ctx, identity, action, resource) (allow, reason, error)
StartDeviceLogin(ctx) (*DeviceFlow, error)
PollDeviceLogin(ctx, flow) (*TokenSet, error)
}| Mode | Set via | Implementation |
|---|---|---|
sdk (default) |
AUTHSEC_MODE=sdk |
authsec.OIDCAdapter — standard OIDC via coreos/go-oidc. Performs OIDC discovery, code exchange, ID token verification, and UserInfo-based access token validation. Authorization uses local admin/tenant membership checks. |
stub |
AUTHSEC_MODE=stub |
stub_sdk.StubClient — accepts stub:<email> tokens. Authorize checks against STUB_ALLOWLIST env var. For development and testing only. |
The adapter pattern allows swapping the real AuthSec SDK in when available, without changing any handler, middleware, or proxy logic. The interface is the single dependency boundary — all auth decisions flow through it.
The gateway token flows through exactly one code path:
config.Load() → TenantEntry.GatewayToken → Tenant.GatewayToken
→ proxy.SetUpstreamHeaders() → req.Header.Set("Authorization", "Bearer " + token)
At no point is the token:
- Included in any response to clients
- Stored in cookies or session data
- Logged (redacted by the logging handler)
- Passed through query parameters (rejected at the guard layer)
- Included in error messages
The proxy implements the overwrite strategy for X-Forwarded-* headers:
- X-Forwarded-For: Set to
request.RemoteAddr(the actual TCP peer). Any client-supplied value is discarded. - X-Forwarded-Proto: Set to
httporhttpsbased onrequest.TLS. - X-Forwarded-Host: Set to
request.Host(the Host header as received by the proxy).
This prevents clients from injecting spoofed IP addresses or protocol claims. The upstream OpenClaw Gateway should be configured to trust these headers exclusively from the proxy's IP.
The upstream gateway should:
- Set
trusted_proxiesto the proxy's IP address(es) - Only accept
X-Forwarded-*headers from trusted proxies - Use token-based authentication mode (not password)
Client Proxy AuthSec (OIDC)
| | |
|--- GET /login --------------->| |
| |--- BeginBrowserLogin() ------->|
|<-- 302 to OIDC provider ------| |
|--- Auth with provider --------|------------------------------->|
|<-- 302 to /callback ----------| |
|--- GET /callback?code=... --->|--- CompleteBrowserLogin() ---->|
| |<-- Identity + TokenSet --------|
| |--- Check admin / claims |
| |--- Create server-side session |
|<-- Set-Cookie: oc_session ----| |
| | |
|--- GET / (with cookie) ------>|--- Validate session |
| |--- Resolve tenant |
| |--- Authorize(tenant:id) ------>|
| |--- Inject gateway token ------>| Upstream
|<-- Proxied response ----------|<-------------------------------|
Sessions are stored server-side in memory with TTL-based expiration. The cookie contains only a signed session ID (not session data). A background goroutine periodically evicts expired sessions.
Rate limiting uses a per-IP token bucket algorithm:
- Scope: Only
/loginand/callbackendpoints (unauthenticated paths that interact with the OIDC provider) - Default: 10 requests per minute per IP
- Reset: The bucket resets after the window expires
- Why not on proxied paths: Authenticated proxy requests are protected by the auth layer, and rate-limiting them could degrade service for legitimate high-throughput tenants
The authsec.Client.Authorize() method is called for every proxied request with resource = "tenant:<tenant_id>".
For the OIDC adapter, access is granted if ANY of the following is true:
- The user's email matches
AUTHSEC_ADMIN_EMAIL - The user's subject matches
AUTHSEC_ADMIN_SUB - The user's
TenantIDsclaim includes the target tenant - The user has no
TenantIDsin their claims (no tenant restrictions)
Additionally, the auth middleware enforces AUTHSEC_REQUIRED_CLAIM (e.g., groups:openclaw) at the middleware level, before the request reaches the proxy handler. Admins bypass required claim checks.
For the stub adapter, access is granted if the email is in STUB_ALLOWLIST or the sub matches AUTHSEC_ADMIN_SUB.
The proxy applies strict header hygiene at two layers: the middleware guard (security.OverwriteForwardedHeaders) and the reverse proxy rewrite hook (proxy.NewHTTPProxy).
All X-Forwarded-* headers are overwritten from the actual TCP connection. The proxy never appends to or trusts client-supplied values:
| Header | Source | Implementation |
|---|---|---|
X-Forwarded-For |
net.SplitHostPort(request.RemoteAddr) |
Middleware + Rewrite hook |
X-Forwarded-Proto |
request.TLS != nil → https, else http |
Middleware + Rewrite hook |
X-Forwarded-Host |
request.Host |
Middleware + Rewrite hook |
This is enforced in two places to guarantee correctness regardless of handler ordering:
security.OverwriteForwardedHeaders— HTTP middleware applied to every inbound request before routing.proxy.NewHTTPProxyRewrite hook — applied again when building the outbound upstream request, ensuring the reverse proxy itself cannot leak stale values.
Cookie— client session cookies are never forwarded upstream.Authorization— replaced with the gateway bearer token.- All hop-by-hop headers (
Connection,Keep-Alive,Proxy-Authenticate,Proxy-Authorization,Te,Trailer,Transfer-Encoding).
Set-Cookie— upstream cookies must not reach the client.
When running behind an external load balancer or ingress controller, that layer must also overwrite X-Forwarded-* headers before they reach the proxy. In Kubernetes, the NGINX ingress controller does this by default. If using another ingress, verify that it does not blindly pass through client-supplied forwarded headers.
Deploy one OpenClaw Gateway instance per tenant. This provides the strongest isolation:
- Separate processes, memory spaces, and data directories.
- A vulnerability or misconfiguration in one tenant's gateway cannot affect another.
- Each gateway has its own unique bearer token.
- Resource limits (CPU, memory) can be tuned per tenant.
In Kubernetes, this means deploying a separate OpenClaw Deployment + PVC per tenant. The proxy's TENANT_CONFIG_JSON maps each tenant ID to its dedicated upstream URL and gateway token.
Running multiple tenants through a single OpenClaw Gateway:
- Relies entirely on the gateway's internal isolation (if any).
- Shares the same bearer token across tenants unless the gateway supports per-tenant tokens natively.
- Couples resource usage: one tenant's heavy workload can starve another.
- Increases blast radius if the gateway process is compromised.
| Control | Single-tenant | Multi-tenant (recommended) | Multi-tenant (shared gateway) |
|---|---|---|---|
| Separate gateway process | N/A | Yes | No |
| Separate gateway token | N/A | Yes | Depends on gateway support |
| Separate data volume | N/A | Yes | No |
| NetworkPolicy scoped | Yes | Per-tenant | Shared |
| Independent resource limits | N/A | Yes | No |
The upstream OpenClaw Gateway must be told which source IPs are trusted proxies, so it correctly honours X-Forwarded-* headers instead of treating them as untrusted client input.
Set OPENCLAW_TRUSTED_PROXIES to the internal Docker network CIDR:
# .env
OPENCLAW_TRUSTED_PROXIES=172.20.0.0/16This is the default in the provided docker-compose.yml.
The Helm chart sets OPENCLAW_TRUSTED_PROXIES=10.0.0.0/8 by default in the OpenClaw Deployment, which covers the standard Kubernetes pod CIDR. Narrow this to your cluster's actual pod CIDR for tighter security:
openclaw:
extraEnv:
- name: OPENCLAW_TRUSTED_PROXIES
value: "10.244.0.0/16" # your cluster's pod CIDRWhen OpenClaw is accessed exclusively through the proxy, its built-in device-pairing authentication is unnecessary and should be disabled to prevent bypass:
OPENCLAW_TAILSCALE_AUTH_ENABLED=falseThe Helm chart and docker-compose configuration both set this by default.
If the gateway supports skipDevicePairingForTrustedProxy, enable it so that requests arriving from the trusted proxy CIDR skip the pairing flow entirely while requests from unexpected sources are still challenged:
OPENCLAW_SKIP_DEVICE_PAIRING_FOR_TRUSTED_PROXY=true
OPENCLAW_TRUSTED_PROXIES=10.244.0.0/16This gives a defense-in-depth guarantee: even if the NetworkPolicy is misconfigured and a non-proxy pod reaches the gateway, the gateway still demands authentication.
The security.RejectTokenQueryParam middleware rejects any request whose URL contains query parameters named token, gateway_token, auth, or access_token with HTTP 400. This is enforced before any other handler runs.
Why: URLs are logged by web servers, proxies, CDNs, and browsers. Tokens in query strings are visible in Referer headers, browser history, and server access logs. Rejecting them at the edge eliminates this entire class of leakage.
The gateway bearer token is injected by proxy.SetUpstreamHeaders() into the Authorization header of the outbound request to the upstream gateway. The token:
- Is loaded from environment variables (or Kubernetes Secrets) at startup.
- Is stored only in the in-memory
Tenantstruct. - Is set on the outbound request only — it never appears in any response to the client.
- Is redacted in structured logs (any log attribute matching
token,secret,password,authorization,cookie, orhmacis replaced with[REDACTED]).
| Credential | Scope | Storage | Lifetime |
|---|---|---|---|
| OIDC access token | Identifies the user to the proxy | Server-side session (memory) | Session TTL (default 8h) |
| Session cookie | Binds browser to session | HMAC-signed cookie (client) | Session TTL |
| Gateway bearer token | Authenticates proxy to upstream | Environment / K8s Secret | Static (rotate manually) |
The OIDC access token and the gateway bearer token are completely separate credentials. The proxy translates between them: it validates the user's OIDC token, authorizes access, then substitutes the gateway token for the upstream call. At no point are the two tokens mixed or exposed to the wrong party.