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
/mcpwithout a browser. Use a token or an MCP-aware OAuth proxy. OAuth proxies can authenticate agents through dynamic client registration or client ID metadata documents. See 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. 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.
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:
FROM caddy:2
COPY Caddyfile /etc/caddy/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_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=100msThe 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):
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_DOMAINSmust 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_SECRETmust decode to 16, 24, or 32 bytes. Useopenssl rand -hex 16to create a valid 32-character value. Do not useopenssl rand -base64 32because it creates 44 characters and oauth2-proxy will not start.- Caddy must send
X-Forwarded-Proto: httpsusing the twoheader_uplines 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 aws://connection. The browser blocks it as mixed content on anhttpspage and the editor does not load. oauth2-proxy forwards the header automatically. SetOAUTH2_PROXY_REVERSE_PROXY=trueso oauth2-proxy trusts forwarded headers for client IP addresses and post-login redirects. SetFLUSH_INTERVAL=100msto follow the never-buffer rule. - Bind to IPv6 where the platform's private network requires it. Railway's is IPv6-only, so
OK_BIND=::andHTTP_ADDRESS=[::]:4180, and Caddy dials the*.railway.internalnames.
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 externalUrlOpenKnowledge 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>
EOFThen edit .env and fill in IDP_CLIENT_ID and IDP_CLIENT_SECRET from your Google client.
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 } } ] } }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:8085Then 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: serverroute answers the browser with "This is an MCP route." Split/mcp(agents) from the catch-all (editor). allow_websockets: trueon the editor route, or the editor loads but never syncs (the/collabsocket is blocked).- Set
preserve_host_header: true. This sends the public host to the server as itsexternalUrl. Without it, every request returns403. - Enable both DCR and CIMD.
mcp_dynamic_client_registration: truesupports Cursor.mcp_allowed_client_id_domainssupports Claude. Pomerium fetches Claude'sclient_idURL 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 insecurews://socket. Make the proxy forwardX-Forwarded-Proto: https(Caddy:header_up X-Forwarded-Proto https). - Every request returns
403. The requestHostdoes not matchOK_EXTERNAL_URL. Configure the proxy to preserve the client'sHost. For Caddy, useheader_up Host {host}. SetOK_EXTERNAL_URLto 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/mcppath is behind the browser login instead of a token. Route/mcparound 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). /mcphangs or times out. A proxy is buffering the streamed response. Turn off buffering for/mcp. In nginx, setproxy_bufferingtooff. For oauth2-proxy, setFLUSH_INTERVAL=100ms. Caddy streams by default.
Once auth is in place, Connect remote agents covers how each client presents its credential.