---
summary: "Publish a loopback Gateway through a Cloudflare Tunnel and authenticate every client with Cloudflare Access"
read_when:
  - You want a public HTTPS Gateway URL without opening a port
  - You want Cloudflare Access (SSO) to authenticate the Control UI
  - Your CLI, TUI, or nodes get HTTP 302 from a Cloudflare-fronted Gateway
title: "Cloudflare Tunnel and Access"
---

Run the Gateway on loopback, publish it through a Cloudflare Tunnel, and let Cloudflare
Access authenticate every request before it reaches OpenClaw. The Gateway keeps
`gateway.bind: "loopback"`, so no port is exposed and no inbound firewall rule is
needed; `cloudflared` dials out from the host.

This is one supported remote-access topology alongside [Tailscale](/gateway/tailscale)
and an [SSH tunnel](/gateway/remote). Choose it when you want a
stable public HTTPS URL and identity-provider SSO in front of the Control UI.

## Before you begin

- A Cloudflare account with the zone for your hostname, and Cloudflare Zero Trust enabled.
- `cloudflared` installed on the Gateway host, and on any machine that will use the CLI.
- A running Gateway on `127.0.0.1:18789` with `gateway.bind: "loopback"`.
- Familiarity with [trusted-proxy auth](/gateway/trusted-proxy-auth), which this topology uses.

## How the pieces fit

```text
browser / CLI / node  ->  Cloudflare Access (identity)  ->  Tunnel  ->  127.0.0.1:18789
```

Access authenticates the request and injects identity headers. The Gateway does not
re-authenticate the person or verify the Access JWT signature; it checks the trusted
proxy source and configured header presence, then trusts the user header. Because
`allowLoopback` also lets other local processes present those headers, keep the Gateway
port private to the host and run only trusted workloads there.

## Step 1: Route the tunnel to loopback

Add an ingress rule mapping your hostname to the Gateway port, then run `cloudflared`
as a service on the Gateway host:

```yaml
tunnel: <tunnel-id>
credentials-file: /root/.cloudflared/<tunnel-id>.json
ingress:
  - hostname: gateway.example
    service: http://localhost:18789
  - service: http_status:404
```

See Cloudflare's own documentation for creating the tunnel and DNS record.

## Step 2: Protect the hostname with Access

Create an Access application for `gateway.example` with a policy that allows your
users. Note the two headers Access adds to authenticated requests, because the Gateway
consumes them in the next step:

- `cf-access-authenticated-user-email` — the authenticated identity.
- `cf-access-jwt-assertion` — Access's signed assertion. OpenClaw checks only that this
  header is present and non-blank; it does not verify the JWT signature.

## Step 3: Trust those headers in the Gateway

Set `gateway.auth.mode` to `trusted-proxy` and name the Access headers. `allowLoopback`
is required here: `cloudflared` connects from `127.0.0.1`, and trusted-proxy auth
otherwise expects a non-loopback proxy.

```json5
{
  gateway: {
    bind: "loopback",
    trustedProxies: ["127.0.0.1", "::1"],
    auth: {
      mode: "trusted-proxy",
      trustedProxy: {
        userHeader: "cf-access-authenticated-user-email",
        requiredHeaders: ["cf-access-jwt-assertion"],
        allowLoopback: true,
      },
    },
  },
}
```

Requiring `cf-access-jwt-assertion` adds a second presence check, not cryptographic
verification. A local process that can connect to the Gateway can submit both headers,
so do not treat this setting as a defense against untrusted local code. The security
boundary is the locked-down loopback port plus Cloudflare Access and the tunnel being
the only path for external traffic.

## Step 4: Decide how nodes and workers get in

Access protects every route on the hostname, including the ones nodes use. A node can
authenticate to Access on every leg it needs — the join request, the main Gateway
WebSocket, the worker socket, and worker transfers — so the recommended path exposes
nothing publicly.

**Recommended: give the node an Access service token.** Add a Service Auth policy to the
application, then on the node host:

```bash
export CF_ACCESS_CLIENT_ID="<client-id>"
export CF_ACCESS_CLIENT_SECRET="<client-secret>"
openclaw connect https://gateway.example/j/<code> --service
```

`openclaw connect` persists these as env SecretRefs under
`gateway.cloudflareAccess.clientId` / `clientSecret`; see [Node CLI](/cli/node). The only
cost is that the node needs those two values before the join command, so a join link is no
longer paste-and-go on its own.

**Alternative: exempt the self-authenticating routes.** Allow `/j/*` and
`/__openclaw__/worker` without Access identity, keeping WebSocket upgrade enabled on the
worker route. Both enforce their own short-lived credentials — a join code is single-use
with a TTL, rate-limited per IP, and answers failures with an opaque 404; worker admission
carries its own expiring credential. This keeps join links paste-and-go, at the cost of
making those two routes publicly reachable. Prefer the service token unless you need that
onboarding flow. See [Nodes](/nodes#gateway-deployments-that-cannot-host-nodes).

If you do neither, `openclaw connect` fails against the tunnel even though the browser
works, because the join request is redirected to the Access login page.

## Step 5: Connect each client

**Control UI.** Open `https://gateway.example` and sign in through Access. With
trusted-proxy auth the Gateway maps your Access identity to an operator session.

**CLI and TUI.** These do not carry browser cookies, so they present an Access token on
the WebSocket upgrade. Configure `gateway.remote.edgeAuth` as described in
[Remote access](/gateway/remote#gateway-behind-an-identity-aware-proxy), then run
`cloudflared access login https://gateway.example` once to cache a token.

**Nodes.** Follow the choice made in step 4.

## Verify

```bash
openclaw tui
```

Expect the TUI to reach `wss://gateway.example` and show `connected`. A first
connection may report `device pairing required`; approve it in the Control UI under
Settings → Devices, or run `openclaw devices approve --latest` on the Gateway host.

Reaching the Gateway's own pairing prompt is itself the proof that Access was
satisfied — an unauthenticated request never gets that far.

## Production readiness

- Keep `gateway.bind: "loopback"`. Binding wider re-exposes the Gateway beside the
  tunnel and bypasses Access entirely.
- Keep `trustedProxies` limited to loopback. It is the list of addresses whose identity
  headers the Gateway will believe.
- `trustedProxy.deviceAutoApprove` can pair devices automatically for
  Access-authenticated identities. It removes a manual approval step; enable it only
  when you accept that anyone who passes Access gets a paired device with the scopes you
  list.
- Access tokens expire on the application's session duration. Expect CLI users to re-run
  `cloudflared access login` when their token lapses.

## Troubleshooting

| Symptom                                                             | Cause and fix                                                                                                                                     |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `gateway rejected websocket upgrade (HTTP 302)` from the CLI or TUI | Access intercepted the upgrade. Configure `gateway.remote.edgeAuth`; see [Remote access](/gateway/remote#gateway-behind-an-identity-aware-proxy). |
| Browser works, `openclaw connect` fails                             | Node routes are still behind Access. Apply one of the options in step 4.                                                                          |
| `Exec provider ... exited with code 1`                              | The exec secret provider runs with a scrubbed environment; `cloudflared` needs `passEnv: ["HOME"]` to read its cached token.                      |
| `secrets.providers.*.command must not be a symlink`                 | Point `command` at the resolved binary, not a package-manager symlink.                                                                            |
| Gateway starts but every request is anonymous                       | `allowLoopback` is unset, so headers from the local `cloudflared` are ignored.                                                                    |

## Related

- [Remote access](/gateway/remote)
- [Trusted-proxy auth](/gateway/trusted-proxy-auth)
- [Nodes](/nodes)
- [Tailscale](/gateway/tailscale)
- [Authentication](/gateway/authentication)
