Authentication
Control who can reach your knowledge base.
How access works
To control access, you put a gate in front of the server, and that gate has to cover two kinds of clients that sign in differently:
- People use the editor in a browser. They sign in with an ordinary login (Google or your SSO), and the browser then sends a session cookie on every request, including the live-editing connection.
- AI agents connect to
/mcpwith no browser, so an agent presents a token instead. Usually that is a preset token you generate. Some MCP-aware proxies can instead give the agent its own OAuth popup (via dynamic client registration or client ID metadata documents), so it signs in and receives its token automatically instead of using a preset one; see MCP-native edges.
A login fits people but not agents, and a token fits agents but not the editor, so a complete setup provides both: a login on the editor and a token on /mcp. That pairing is the split, and it shapes every recipe below.
You add the gate in one of two places: on the tunnel or network that reaches the server, or in a proxy in front of it. A tunnel or private network is quickest when you run the server yourself (npm method); a proxy suits a container on a hosting platform (Docker method). The two mix, so a container can sit behind a tunnel and let the tunnel gate it.
Tunnels and private networks
If you reach the server through a tunnel or private network, that same layer is the natural place to gate access.
Tailscale
The simplest option: it sidesteps the split. tailscale serve admits only devices on your tailnet, so the network itself is the authentication: one gate covering the editor and agents alike, with no login pages and no tokens to mint. Narrow it further with tailnet ACLs. But serve is tailnet-only: it can't reach mobile or cloud agents (Claude iOS, Cursor cloud), which dial from the provider's cloud, not your tailnet. For those, make it public with tailscale funnel plus a gate, see Example: Pomerium. If everyone who needs access can join your tailnet, serve is usually the right answer.
ngrok
It puts a login at its edge, and expresses the split in a single path-branched policy: an OAuth login for the browser, a Basic credential for agents.
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 headlessly by sending the Basic credential as a header (see Connecting agents). Replace CHANGE_ME with a strong secret (openssl rand -hex 24). For SSO instead of consumer OAuth, ngrok's --oidc covers Okta, Azure AD, and Google Workspace.
Cloudflare Tunnel
It pairs with Cloudflare Access: a login policy on the hostname for the browser, and service tokens for agents (standing credentials, with no hourly expiry). Its Managed OAuth can go one step further and serve MCP's own OAuth popup to agents (see MCP-native edges); it requires your domain on Cloudflare.
In front of a container
A container on a hosting platform gets a public domain with no login of its own, so the gate is a proxy you add. Browsers always get a login; the choice is how agents authenticate:
- A static bearer token you generate once and every agent sends as a header. Simplest to run, and the recipe below works this way; the trade-off is one secret shared by everyone.
- 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, and the routing is what makes the split clean. Caddy is the public ingress: it sends /mcp straight to the server behind a shared bearer token, and everything else through oauth2-proxy, which runs the Google login for the browser. Agents never touch the login; browsers never see the token; and because /mcp bypasses oauth2-proxy, its streaming stays clear of the login proxy entirely. Three services, none needing 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 is the public ingress. Bake the config into a small image, since managed platforms can't mount a file:
FROM caddy:2
COPY Caddyfile /etc/caddy/Caddyfile:{$PORT} {
# Agents: a shared bearer token, straight to the server.
@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; that string is what agents send.
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.openssl rand -hex 16gives a valid 32-character value;openssl rand -base64 32gives 44 characters, and oauth2-proxy refuses to start.- Caddy must send
X-Forwarded-Proto: https(the twoheader_uplines above). TLS terminates at the platform edge, so Caddy speaks plain HTTP inward and would otherwise report the request ashttp, so the server hands the editor aws://socket, which the browser blocks as mixed content on anhttpspage and the editor hangs. oauth2-proxy relays that header to its upstream on its own;OAUTH2_PROXY_REVERSE_PROXY=trueis what makes it trust the forwarded headers for its own client-IP and post-login redirect logic, which theWHITELIST_DOMAINSnote above depends on.FLUSH_INTERVAL=100msis the never-buffer rule for the same path. - 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 when you need OpenKnowledge from mobile or cloud agents (Claude iOS, Cursor cloud) with a per-user login: a public endpoint where each agent gets its own OAuth token, no shared secret and no tailnet membership. It is two layers, not one. Any public recipe (ngrok, Cloudflare, the Caddy one above) gives you the public endpoint; Pomerium adds per-user OAuth at the gate, serving MCP's own OAuth on /mcp and a cookie login for the editor from one config.
The reason those clients specifically need this: Claude iOS and Cursor's cloud agents dial your server from the provider's cloud, not from the device, so the endpoint has to be genuinely public. A phone has no mcp-remote fallback and isn't on your tailnet, so tailscale serve can't reach it; tailscale funnel (public) plus Pomerium can.
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: per-user Google login; MCP OAuth on /mcp, cookie login on the 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 $(...) execute now; they are not literal values).
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 authenticates with client ID metadata documents (CIMD); Cursor with
# 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:Bring it up with docker compose up -d. Two portability notes: the pomerium/pomerium:main tag is required for now, since Pomerium's MCP support is recent and not yet in a stable release. And on Linux without Docker Desktop, host.docker.internal may not reach a loopback-bound server (host-gateway resolves to the Docker bridge IP, not 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. Connecting 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).preserve_host_header: trueso the public host reaches the server as itsexternalUrl; without it every request is a403.- Both DCR and CIMD.
mcp_dynamic_client_registration: truecovers Cursor;mcp_allowed_client_id_domainscovers Claude, whoseclient_idis a URL that Pomerium fetches and checks against that 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: serves the MCP OAuth popup at Cloudflare's edge, alongside the Access login for the UI; it requires your domain on 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. TheHostreaching the server doesn't matchOK_EXTERNAL_URL, usually because the proxy rewroteHostto the upstream address. Preserve the client'sHost(Caddy:header_up Host {host}) and pointOK_EXTERNAL_URLat the public origin. Full proxy rules. - The browser login loops back to the login page. oauth2-proxy is rejecting its own post-login redirect because the domain isn't allowed. Add your 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 Connecting agents). /mcphangs or times out. A proxy in front is buffering the streamed response. Turn buffering off on/mcp(nginxproxy_buffering off;, oauth2-proxyFLUSH_INTERVAL=100ms; Caddy streams by default).
Once auth is in place, Connecting agents covers how each client presents its credential.