# Authentication (https://openknowledge.ai/docs/remote-control/authentication)

Control who can reach your knowledge base.

## How access works

Add access control in front of the server. The browser and MCP clients use different authentication methods:

- **Browser users** sign in with Google or your SSO. The browser sends a session cookie with editor and live collaboration requests.
- **AI agents** connect to `/mcp` without a browser. Use a token or an MCP-aware OAuth proxy. OAuth proxies can authenticate agents through [dynamic client registration](https://datatracker.ietf.org/doc/html/rfc7591) or [client ID metadata documents](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/). See [MCP-native edges](https://openknowledge.ai/docs/remote-control/authentication#mcp-native-edges).

For a public deployment, protect the editor with a browser login. Protect `/mcp` with a token or MCP-compatible OAuth.

Add access control to the tunnel, private network, or proxy in front of the server. Use a tunnel or private network with the [CLI method](https://openknowledge.ai/docs/remote-control/methods/cli). Use a proxy with the [Docker method](https://openknowledge.ai/docs/remote-control/methods/docker). A container can also run behind a protected tunnel.

## Tunnels and private networks

A tunnel or private network can restrict access before requests reach the server.

### Tailscale

`tailscale serve` limits access to devices on your tailnet. It protects both the editor and `/mcp` without a separate login or token. Use [tailnet ACLs](https://tailscale.com/kb/1018/acls) to restrict access further.

`tailscale serve` cannot reach mobile or cloud agents such as Claude on iOS or Cursor cloud because they connect from the provider's network. To support those clients, use `tailscale funnel` with access control such as [Pomerium](https://openknowledge.ai/docs/remote-control/authentication#example-pomerium).

### ngrok

ngrok can apply separate access rules by path. Use OAuth for the editor and Basic authentication for `/mcp`.

```yaml title="policy.yaml"
on_http_request:
  - expressions: ["req.url.path.startsWith('/mcp')"]
    actions:
      - type: basic-auth
        config: { credentials: ["agent:CHANGE_ME"] }
  - expressions: ["!req.url.path.startsWith('/mcp')"]
    actions:
      - type: oauth
        config: { provider: google }
```

Run the tunnel with `--traffic-policy-file policy.yaml`. Browsers get the Google login. An agent connects by sending the Basic credential as a header. See [Connect remote agents](https://openknowledge.ai/docs/remote-control/connecting-agents). Replace `CHANGE_ME` with a strong secret from `openssl rand -hex 24`. For SSO, ngrok's `--oidc` supports Okta, Azure AD, and Google Workspace.

### Cloudflare Tunnel

Pair it with [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/). Use a login policy on the hostname for browsers and [service tokens](https://developers.cloudflare.com/cloudflare-one/identity/service-tokens/) for agents. Service tokens do not expire every hour. [Managed OAuth](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/) can also provide an OAuth popup for agents. See [MCP-native edges](https://openknowledge.ai/docs/remote-control/authentication#mcp-native-edges). Managed OAuth requires your domain to use Cloudflare.

## In front of a container

Hosting platforms provide a public domain without access control. Add an authentication proxy in front of the container. Use one of these methods for agents:

- A **static bearer token** sent by every agent as a header. The recipe below uses one shared token.
- An **MCP OAuth popup**, where the agent logs in with nothing to copy, and the only path that reaches mobile and cloud connectors (Claude iOS, Cursor cloud). The [Pomerium recipe](https://openknowledge.ai/docs/remote-control/authentication#example-pomerium) below sets it up.

### Example: Caddy + oauth2-proxy

Two small proxy services sit in front of the server. **Caddy** receives public traffic. It sends `/mcp` directly to the server behind a shared bearer token. It sends everything else through **[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/)**, which handles Google login for the browser. Agents do not use the browser login. Browsers do not see the agent token. Because `/mcp` bypasses oauth2-proxy, its streaming response is not interrupted by the login proxy. None of the three services needs a database.

The hostnames below are Railway's private network (`*.railway.internal`, IPv6-only). On another Docker host, swap them for your compose service names.

**Caddy** receives public traffic. Include its configuration in the image because managed platforms may not support mounting a file:

```dockerfile title="Dockerfile (caddy)"
FROM caddy:2
COPY Caddyfile /etc/caddy/Caddyfile
```

```text title="Caddyfile"
:{$PORT} {
	# Agents use a shared bearer token.
	@mcp path /mcp /mcp/*
	handle @mcp {
		route {
			@bad not header Authorization "Bearer {env.MCP_TOKEN}"
			respond @bad "Unauthorized" 401
			reverse_proxy ok.railway.internal:8080 {
				header_up Host {host}
				header_up X-Forwarded-Proto https
			}
		}
	}
	# Everyone else: through the Google login.
	handle {
		reverse_proxy oauth2-proxy.railway.internal:4180 {
			header_up Host {host}
			header_up X-Forwarded-Proto https
		}
	}
}
```

Set `MCP_TOKEN` on the Caddy service to a value from `openssl rand -hex 24`. Agents send this value when they connect.

**oauth2-proxy** runs the browser login. Use the `quay.io/oauth2-proxy/oauth2-proxy:v7` image with your own Google OAuth client (a **Web application** client whose redirect URI is `https://<your-domain>/oauth2/callback`):

```bash title="oauth2-proxy service environment"
OAUTH2_PROXY_PROVIDER=google
OAUTH2_PROXY_CLIENT_ID=<your-google-client-id>
OAUTH2_PROXY_CLIENT_SECRET=<your-google-client-secret>
OAUTH2_PROXY_REDIRECT_URL=https://<your-domain>/oauth2/callback
OAUTH2_PROXY_EMAIL_DOMAINS=example.com
OAUTH2_PROXY_COOKIE_SECRET=<openssl rand -hex 16>
OAUTH2_PROXY_UPSTREAMS=http://ok.railway.internal:8080/
OAUTH2_PROXY_HTTP_ADDRESS=[::]:4180
OAUTH2_PROXY_WHITELIST_DOMAINS=<your-domain>
OAUTH2_PROXY_REVERSE_PROXY=true
OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true
OAUTH2_PROXY_FLUSH_INTERVAL=100ms
```

**The knowledge-base service** takes the usual two declarations plus the IPv6 bind, and no public domain of its own (Caddy is the only way in):

```bash title="knowledge-base service environment"
OK_ALLOW_EXTERNAL=1
OK_EXTERNAL_URL=https://<your-domain>
OK_BIND=::
```

An agent then connects with the token and no browser step:

```bash
--header "Authorization: Bearer <MCP_TOKEN>"
```

Four settings decide whether this boots the first time:

- **`OAUTH2_PROXY_WHITELIST_DOMAINS` must list your public domain.** Behind Caddy, oauth2-proxy builds its post-login redirect from the forwarded host and refuses any host not on this list, so without it the login silently loops back to itself.
- **`OAUTH2_PROXY_COOKIE_SECRET` must decode to 16, 24, or 32 bytes.** Use `openssl rand -hex 16` to create a valid 32-character value. Do not use `openssl rand -base64 32` because it creates 44 characters and oauth2-proxy will not start.
- **Caddy must send `X-Forwarded-Proto: https`** using the two `header_up` lines above. TLS ends at the platform edge, so Caddy uses plain HTTP between the edge and the server. Without this header, the server gives the editor a `ws://` connection. The browser blocks it as mixed content on an `https` page and the editor does not load. oauth2-proxy forwards the header automatically. Set `OAUTH2_PROXY_REVERSE_PROXY=true` so oauth2-proxy trusts forwarded headers for client IP addresses and post-login redirects. Set `FLUSH_INTERVAL=100ms` to follow the [never-buffer rule](https://openknowledge.ai/docs/remote-control/methods/docker#rules-for-anything-in-front-of-the-server).
- **Bind to IPv6 where the platform's private network requires it.** Railway's is IPv6-only, so `OK_BIND=::` and `HTTP_ADDRESS=[::]:4180`, and Caddy dials the `*.railway.internal` names.

### Example: Pomerium

Use this setup when mobile or cloud agents need their own login. Examples include Claude on iOS and Cursor cloud. Each agent gets its own OAuth token, so there is no shared secret or tailnet membership.

This setup has two layers. A public access method such as ngrok, Cloudflare, or the Caddy example above provides the **public endpoint**. [Pomerium](https://www.pomerium.com/) adds **per-user OAuth**. It provides OAuth for `/mcp` and a cookie login for the editor from one configuration.

Claude on iOS and Cursor's cloud agents connect from the provider's cloud, not from your device. The endpoint must be publicly reachable. A phone cannot use the `mcp-remote` fallback and is not on your tailnet. `tailscale serve` cannot reach it. You can use the public `tailscale funnel` with Pomerium instead.

```text
Internet  (Claude / Cursor cloud dial in from here)
   |  https://<host>/ (editor)   +   https://<host>/mcp (agents)
   v  Tailscale funnel: public TLS on your *.ts.net name
   v  Pomerium: MCP OAuth on /mcp, cookie session for the browser editor
   v  OpenKnowledge: loopback, told its public externalUrl
```

**OpenKnowledge** runs on loopback, told its public origin. Pomerium is the only way in, so the server keeps no exposure of its own:

```bash
OK_ALLOW_EXTERNAL=1 ok start -p 24550 --no-open-browser --idle-shutdown off \
  --external-url https://<host>
```

The "external access enabled, no server-side auth" banner is expected here: Pomerium is the auth, and the server stays on loopback.

**Pomerium** runs as a container with a persistent databroker, so sessions and tokens survive a restart. It needs your own Google OAuth client (a **Web application** client whose redirect URI is `https://<host>:8443/oauth2/callback`), plus two secrets and a signing key you generate locally:

```bash
# Run this to write .env. The $(...) expressions execute now.
cat > .env <<EOF
SHARED_SECRET=$(openssl rand -base64 32)
COOKIE_SECRET=$(openssl rand -base64 32)
SIGNING_KEY=$(openssl ecparam -genkey -name prime256v1 -noout | base64 | tr -d '\n')
IDP_PROVIDER=google
IDP_CLIENT_ID=<your-google-client-id>
IDP_CLIENT_SECRET=<your-google-client-secret>
EOF
```

Then edit `.env` and fill in `IDP_CLIENT_ID` and `IDP_CLIENT_SECRET` from your Google client.

```yaml title="config.yaml"
address: ":8080"
autocert: false

# Persistent databroker. The default `memory` store loses every session and
# token on restart, which surfaces as reconnect prompts and an OAuth login loop.
databroker_storage_type: file
databroker_storage_connection_string: file:///data/databroker

# Claude uses client ID metadata documents (CIMD). Cursor uses
# dynamic client registration (DCR). Enable both.
runtime_flags:
  mcp_dynamic_client_registration: true
mcp_allowed_client_id_domains:
  - 'claude.ai'
  - 'claude.com'
  - 'anthropic.com'
  - '*.anthropic.com'

authenticate_service_url: https://<host>:8443

routes:
  # Agents: the MCP endpoint, path-scoped to /mcp.
  - from: https://<host>
    to: http://host.docker.internal:24550
    name: OpenKnowledge MCP
    prefix: /mcp
    preserve_host_header: true
    mcp:
      server:
        path: /mcp
    policy: { allow: { and: [ { email: { is: you@example.com } } ] } }

  # People: the editor and everything else, behind the Google cookie login.
  - from: https://<host>
    to: http://host.docker.internal:24550
    name: OpenKnowledge UI
    preserve_host_header: true
    allow_websockets: true
    policy: { allow: { and: [ { email: { is: you@example.com } } ] } }
```

```yaml title="docker-compose.yaml"
services:
  pomerium:
    image: pomerium/pomerium:main
    restart: unless-stopped
    env_file: .env
    volumes:
      - ./config.yaml:/pomerium/config.yaml:ro
      - pomerium-data:/data
    ports:
      - "127.0.0.1:8085:8080"
    extra_hosts:
      - "host.docker.internal:host-gateway"
volumes:
  pomerium-data:
```

Start it with `docker compose up -d`. The `pomerium/pomerium:main` tag is required because Pomerium's MCP support is not yet in a stable release.

On Linux without Docker Desktop, `host.docker.internal` may not reach a server bound to loopback. `host-gateway` resolves to the Docker bridge IP instead of `127.0.0.1`. Run Pomerium with `--network host`, or bind the server to the bridge gateway and restrict it with a firewall rule.

**Tailscale funnel** makes it public on your `*.ts.net` name (MagicDNS, HTTPS, and Funnel must be enabled for the node):

```bash
tailscale funnel --bg --https=443  https+insecure://127.0.0.1:8085
tailscale funnel --bg --https=8443 https+insecure://127.0.0.1:8085
```

Then point a custom MCP connector in Claude (iOS or Desktop) or Cursor at `https://<host>/mcp` and complete the Google login. [Connect remote agents](https://openknowledge.ai/docs/remote-control/connecting-agents) has the per-client steps.

The settings that are easy to miss:

- **Two routes, not one.** A single `mcp: server` route answers the browser with "This is an MCP route." Split `/mcp` (agents) from the catch-all (editor).
- **`allow_websockets: true`** on the editor route, or the editor loads but never syncs (the `/collab` socket is blocked).
- **Set `preserve_host_header: true`.** This sends the public host to the server as its `externalUrl`. Without it, every request returns `403`.
- **Enable both DCR and CIMD.** `mcp_dynamic_client_registration: true` supports Cursor. `mcp_allowed_client_id_domains` supports Claude. Pomerium fetches Claude's `client_id` URL and checks it against this list.
- **A persistent databroker** (above), or a restart drops every token.

### MCP-native edges

Alternatives to the Pomerium recipe above, as pointers rather than full recipes:

- **[obot mcp-oauth-proxy](https://github.com/obot-platform/mcp-oauth-proxy)**: an MCP-aware OAuth proxy that implements dynamic client registration, the flavor today's clients (Cursor among them) speak.
- **[Cloudflare Managed OAuth](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/)** provides the MCP OAuth popup at Cloudflare's edge. It works with the [Access](https://openknowledge.ai/docs/remote-control/authentication#tunnels-and-private-networks) login for the UI and requires your domain to use Cloudflare.

## Troubleshooting

Common symptoms and their fixes:

- **The editor loads but never syncs, and the browser console shows a `ws://` "Mixed Content" error.** The proxy terminates TLS but reports the request to the server as plain HTTP, so the server hands the editor an insecure `ws://` socket. Make the proxy forward `X-Forwarded-Proto: https` (Caddy: `header_up X-Forwarded-Proto https`).
- **Every request returns `403` with a problem-JSON `"title"` of `Host header not allowed.`.** The request `Host` does not match `OK_EXTERNAL_URL`. Configure the proxy to preserve the client's `Host`. For Caddy, use `header_up Host {host}`. Set `OK_EXTERNAL_URL` to the public origin. See the full [proxy rules](https://openknowledge.ai/docs/remote-control/methods/docker#rules-for-anything-in-front-of-the-server).
- **A request returns `403` with a problem-JSON `"title"` of `Proxied request refused: ...`.** The request reached the server through a proxy that stamps forwarding headers (`X-Forwarded-For`, `Forwarded`, `X-Forwarded-Proto`, ...), but the server has not consented to external exposure. Set BOTH `OK_EXTERNAL_URL` to the public origin and `OK_ALLOW_EXTERNAL=1`; tolerance for forwarding headers requires the pair, and `OK_EXTERNAL_URL` alone is not enough. `OK_ALLOW_EXTERNAL` consents to exposing a server with no authentication of its own, so only set it behind an authenticating edge.
- **The browser login returns to the login page.** oauth2-proxy is rejecting the post-login redirect because the domain is not allowed. Add the public domain to `OAUTH2_PROXY_WHITELIST_DOMAINS`.
- **An agent gets an HTML login page back from `/mcp`.** The `/mcp` path is behind the browser login instead of a token. Route `/mcp` around the login proxy and gate it with a token, or if you use an MCP-OAuth edge, confirm the client supports it (see [Connect remote agents](https://openknowledge.ai/docs/remote-control/connecting-agents)).
- **`/mcp` hangs or times out.** A proxy is buffering the streamed response. Turn off buffering for `/mcp`. In nginx, set `proxy_buffering` to `off`. For oauth2-proxy, set `FLUSH_INTERVAL=100ms`. Caddy streams by default.

Once auth is in place, [Connect remote agents](https://openknowledge.ai/docs/remote-control/connecting-agents) covers how each client presents its credential.