OpenKnowledge

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:

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. Use a proxy with the Docker method. 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 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.

ngrok

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

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. 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. Use a login policy on the hostname for browsers and service tokens for agents. Service tokens do not expire every hour. Managed OAuth can also provide an OAuth popup for agents. See 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 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, 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 (caddy)
FROM caddy:2
COPY Caddyfile /etc/caddy/Caddyfile
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):

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):

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:

--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.
  • 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 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.

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:

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:

# 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.

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 } } ] } }
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):

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 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: an MCP-aware OAuth proxy that implements dynamic client registration, the flavor today's clients (Cursor among them) speak.
  • Cloudflare Managed OAuth provides the MCP OAuth popup at Cloudflare's edge. It works with the Access 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. 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.
  • 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).
  • /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 covers how each client presents its credential.