Identity and external access
Production access layer recipes using Cloudflare Access or oauth2-proxy, path routing, and MCP boundaries.
This guide covers production identity and perimeter access layer patterns for obs-unified. It provides concrete deployment recipes for Cloudflare Access and self-hosted oauth2-proxy with Nginx or Caddy. Adaptable configuration templates are provided in examples/external-access/ for Nginx, Caddy, oauth2-proxy, and Cloudflare Tunnel (cloudflared). These are adaptable reference templates requiring site-specific domain, secret, and TLS certificate setup before deployment, rather than an all-in-one local Compose harness. For collector and reverse-proxy deployment baselines, see Production operations.
Architectural realities & boundaries
Before configuring external access, understand how authentication operates in the obs-unified stack:
[!IMPORTANT] Native per-user RBAC and SSO are absent.
obs-unifieddoes NOT include native per-user Role-Based Access Control (RBAC), user accounts, or native Single Sign-On (SSO / SAML / OIDC).
- There is no database table for dashboard operators or users.
- There are no user roles (for example, Viewer vs. Editor vs. Admin).
- There is no native per-user audit trail or per-user access control within the dashboard.
- The dashboard relies on a single shared
DASHBOARD_PASSWORD. Any operator with dashboard access has full administrative visibility into all telemetry projects and can create or revoke ingest keys.X-Project-Idis used for multi-project tenant scoping and is validated against known database projects (packages/obs-collector/src/auth/dashboard-auth.ts), but this is not an authorization boundary or RBAC mechanism: any authenticated dashboard user can switch to any existing project.- All per-user access control, identity federation, role enforcement, and access audits must be enforced at an external access layer.
Component access boundaries
- Dashboard authentication & programmatic token (
DASHBOARD_PASSWORD,DASHBOARD_TOKEN):- Grounded in
packages/obs-collector/src/auth/dashboard-auth.tsandpackages/obs-collector/src/framework/collector.ts. - Managed in the collector via
dashboardAuth. Browser login viaPOST /auth/logintestsDASHBOARD_PASSWORDusing constant-time comparison (timingSafeEqualStr) and sets an HMAC-SHA256 signedobs_sessioncookie ({ exp }, valid for 7 days,HttpOnly,SameSite=Strict,Secureover HTTPS). - On
/internal/*andGET /auth/check, the collector accepts either a validobs_sessioncookie or a programmatic read/admin bearer token (Authorization: Bearer <DASHBOARD_TOKEN>, validated in constant time). Programmatic client tokens map directly to this server secret:- CLI client (
@obsunified/cli): usesOBS_ADMIN_TOKENforkeys mint,keys list, anddoctor; this value must equal the collector serverDASHBOARD_TOKEN. - MCP server (
@obsunified/mcp-server): usesOBS_DASHBOARD_TOKENfor telemetry inspection; this value must equal the collector serverDASHBOARD_TOKEN.
- CLI client (
- Ingest keys (
INGEST_KEY,OBS_INGEST_KEY) are strictly write-only and cannot read/internal/*. - When unauthenticated, HTML requests (
Accept: text/html) redirect to/dashboard/login, and API requests without a validobs_sessioncookie or Bearer token return401 Unauthorized. - The React dashboard (
packages/dashboard/src/AuthGate.tsx) queriesGET /auth/checkon mount and rendersLogin.tsxwhen unauthenticated.
- Grounded in
- Telemetry ingest API keys (
INGEST_KEY,ingest_keystable):- Grounded in
packages/obs-collector/src/auth/ingest-auth.tsandpackages/obs-collector/src/lib/projects-store.ts. - Managed via
ingestAuth. Telemetry producers sendAuthorization: Bearer <key>orX-API-Key: <key>to/v1/*routes (POST /v1/traces,POST /v1/logs,POST /v1/metrics,POST /v1/ai). - Ingest keys grant write-only access and cannot read dashboard or
/internal/*query APIs.
- Grounded in
- Public health endpoint (
/health):- Publicly accessible on the collector for load balancer health probes and client clock offset synchronization (
serverTimeMs). Must remain reachable without SSO redirection.
- Publicly accessible on the collector for load balancer health probes and client clock offset synchronization (
- Investigation MCP boundary (
@obsunified/mcp-server):- Grounded in
packages/mcp-server/src/index.ts. - Read-only investigation server querying
/internal/*routes. - Configure
OBS_DASHBOARD_TOKENon the MCP client matchingDASHBOARD_TOKENon the collector (orOBS_SESSION_COOKIEfor ad-hoc browser session reuse). Note that while CLI clients configureOBS_ADMIN_TOKEN, MCP configuresOBS_DASHBOARD_TOKEN; both authenticate against the collector serverDASHBOARD_TOKEN. - The MCP client sends
Authorization: Bearer <OBS_DASHBOARD_TOKEN>and optionalX-Project-Id. - The MCP client does not emit Cloudflare Access service token headers (
CF-Access-Client-IdorCF-Access-Client-Secret). Direct connection across a Cloudflare Access gate without a forwarding proxy is unsupported. - Run the MCP server within the trusted internal network or localhost boundary where it can directly reach the collector's port (
http://127.0.0.1:8790or internal cluster servicehttp://obs-collector:8790) usingOBS_DASHBOARD_TOKEN.
- Grounded in
X-Project-IdHandling:- The collector requires
X-Project-Idfor multi-project tenant scoping in the dashboard and MCP, validating it against registered project IDs in the database. - The reverse proxy must pass
X-Project-Idthrough to the collector; do not strip it globally.
- The collector requires
Route & policy topology
| Path | Backend | Access Layer Policy | Credential | Purpose |
|---|---|---|---|---|
/health | Collector :8790 | Public / Bypass | None | Liveness & clock offset sync |
/v1/* | Collector :8790 | Bypass SSO | Ingest API Key (X-API-Key or Bearer) | OTLP spans, logs, metrics, AI calls |
/auth/* | Collector :8790 | SSO Protected | obs_session | Dashboard session management |
/internal/* | Collector :8790 | SSO / Internal VPC | obs_session cookie or Bearer DASHBOARD_TOKEN | Telemetry & evidence queries |
/ | Dashboard :5173 | SSO Protected | IdP Session Cookie | React SPA assets |
Logging in and out from scripts
POST /auth/login takes a JSON body with the dashboard password and answers with an obs_session cookie (HttpOnly, SameSite=Strict, 7-day Max-Age):
curl -i -X POST http://localhost:5173/auth/login \
-H 'Content-Type: application/json' \
-d '{"password":"<DASHBOARD_PASSWORD>"}'
# HTTP/1.1 200 OK
# set-cookie: obs_session=...; Path=/; HttpOnly; SameSite=Strict; Max-Age=604800
# {"success":true}The dashboard origin (:5173) proxies /auth/* to the collector, so the same call works against :8790 directly. Add -c cookies.txt to save the cookie and -b cookies.txt to reuse it on /internal/*. A wrong password returns 401, and repeated failures are rate limited per client IP (set TRUST_PROXY=true behind a reverse proxy so that IP is the real client).
Logout only clears the browser cookie (Max-Age=0). Sessions are stateless signed cookies, so a copy of an issued obs_session stays valid until its 7-day expiry or until you rotate DASHBOARD_PASSWORD. Rotate the password to invalidate every outstanding session.
Rotating an ingest key
Ingest keys are project-scoped and can be rotated without downtime:
- Mint a new key:
obs-unified keys mint --project <id> --name <label>(orPOST /internal/projects/:id/keys). - Switch clients to the new
OBS_INGEST_KEYand redeploy. - Revoke the old key:
DELETE /internal/projects/:id/keys/:keyId(findkeyIdwithobs-unified keys list --project <id>), orobs-unified keys revoke <keyId>with CLI 1.2 or newer.
curl -X DELETE -H "Authorization: Bearer $DASHBOARD_TOKEN" \
http://localhost:8790/internal/projects/default/keys/<keyId>Revocation is not instant: the collector caches ingest authorization for 60 seconds, so a revoked key can keep ingesting for up to about a minute (observed: accepted at 55 seconds, rejected at 60). A revoked bootstrap key (the one supplied through INGEST_KEY) stays revoked after a restart; the collector does not recreate it. Use the newly minted key instead.
Recipe 1: Cloudflare Access (Zero Trust)
Cloudflare Access protects origin infrastructure with edge-enforced identity policies for human operators while telemetry ingestion bypasses Access and MCP queries run via internal routing.
Hostname separation
The recommended configuration splits public ingest from authenticated dashboard traffic:
obs.example.com: Protected by Cloudflare Access. Routes/to Dashboard:5173,/internal/*and/auth/*to Collector:8790.ingest.example.com: Bypasses Access. Routes/v1/*and/healthdirectly to Collector:8790.
Step 1: Cloudflare Access application setup
- In the Cloudflare Zero Trust Dashboard, navigate to Access > Applications.
- Click Add an Application and select Self-hosted.
- Application Configuration:
- Application Name:
obs-unified Dashboard - Application Domain:
obs.example.com - Session Duration:
24 hours
- Application Name:
- Access Policies:
- Policy: Human Operators: Action
Allow, Rule typeInclude>Emails ending in@company.com(or IdP SAML/OIDC Groupengineering-observability).
- Policy: Human Operators: Action
- MCP Boundary Note: Because
@obsunified/mcp-serverdoes not emitCF-Access-Client-*headers, do not configure service tokens onobs.example.comexpecting the standard MCP CLI to traverse it directly. Keep MCP local or on an internal VPC route.
Step 2: Cloudflare Tunnel (cloudflared) configuration
Deploy cloudflared using the adaptable template in examples/external-access/cloudflared-config.yml. Tunnel UUID, credentials file setup, and ingress hostname configuration are required. Save as /etc/cloudflared/config.yml:
tunnel: <TUNNEL_UUID>
credentials-file: /etc/cloudflared/<TUNNEL_UUID>.json
ingress:
# Telemetry Ingest (Direct pass-through, authenticated via Ingest Key)
- hostname: ingest.example.com
path: ^/v1/
service: http://127.0.0.1:8790
- hostname: ingest.example.com
path: ^/health$
service: http://127.0.0.1:8790
# Dashboard & Query Endpoints (Protected by Cloudflare Access)
- hostname: obs.example.com
path: ^/health$
service: http://127.0.0.1:8790
- hostname: obs.example.com
path: ^/(auth|internal)/
service: http://127.0.0.1:8790
- hostname: obs.example.com
service: http://127.0.0.1:5173
- service: http_status:404Recipe 2: Self-hosted oauth2-proxy with Nginx
Deploy oauth2-proxy alongside Nginx in Kubernetes or cloud VPCs to gate access against standard OIDC providers (Dex, Keycloak, Okta, Google).
Step 1: oauth2-proxy configuration
Configure oauth2-proxy via environment variables (see template in examples/external-access/oauth2-proxy.env.example) or oauth2-proxy.cfg. Secret setup is required: generate a high-entropy cookie secret and provision OIDC client credentials:
## /etc/oauth2-proxy/oauth2-proxy.cfg
http_address = "0.0.0.0:4180"
upstreams = [ "static://202" ]
## OIDC Provider Settings
provider = "oidc"
oidc_issuer_url = "https://accounts.google.com"
client_id = "YOUR_OIDC_CLIENT_ID"
client_secret = "YOUR_OIDC_CLIENT_SECRET"
## Cookie Configuration
cookie_name = "_oauth2_proxy"
cookie_secret = "GENERATE_RANDOM_16_24_OR_32_BYTE_STRING="
cookie_secure = true
cookie_httponly = true
cookie_samesite = "lax"
cookie_expire = "168h"
## Authorization Boundaries
email_domains = [ "company.com" ]
## Header Settings
pass_user_headers = true
set_xauthrequest = true
## Skip authentication for public telemetry ingest and health endpoints
skip_auth_routes = [
"^/health$",
"^/v1/.*"
]Step 2: Nginx reverse proxy configuration
Deploy Nginx with the http_auth_request_module using the adaptable server fragment template in examples/external-access/nginx.conf (or examples/external-access/caddy.Caddyfile for Caddy). TLS certificate provisioning, secret configuration, and upstream host setup are required:
upstream collector_backend {
server 127.0.0.1:8790;
keepalive 32;
}
upstream dashboard_backend {
server 127.0.0.1:5173;
keepalive 16;
}
upstream oauth2_proxy {
server 127.0.0.1:4180;
}
server {
listen 443 ssl http2;
server_name obs.company.com;
ssl_certificate /etc/ssl/certs/obs.company.com.crt;
ssl_certificate_key /etc/ssl/private/obs.company.com.key;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
# 1. Health Probe (Bypass auth)
location = /health {
proxy_pass http://collector_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 2. Telemetry Ingest (Bypass SSO, verified by Collector Ingest Key)
location /v1/ {
client_max_body_size 10m;
proxy_pass http://collector_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 3. oauth2-proxy Auth Subrequest
location = /oauth2/auth {
internal;
proxy_pass http://oauth2_proxy;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header Host $host;
}
location /oauth2/ {
proxy_pass http://oauth2_proxy;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Scheme $scheme;
}
# 4. Dashboard Authentication Routes (/auth/*) -> Collector (:8790)
location /auth/ {
auth_request /oauth2/auth;
error_page 401 = /oauth2/sign_in?rd=$scheme://$host$request_uri;
proxy_pass http://collector_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
}
# 5. Internal Query APIs (Protected by SSO) -> Collector (:8790)
location /internal/ {
auth_request /oauth2/auth;
error_page 401 = /oauth2/sign_in?rd=$scheme://$host$request_uri;
auth_request_set $user $upstream_http_x_auth_request_user;
auth_request_set $email $upstream_http_x_auth_request_email;
proxy_pass http://collector_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Auth-User $user;
proxy_set_header X-Auth-Email $email;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Project-Id $http_x_project_id;
}
# 6. Dashboard SPA (Protected by SSO) -> Dashboard (:5173)
location / {
auth_request /oauth2/auth;
error_page 401 = /oauth2/sign_in?rd=$scheme://$host$request_uri;
proxy_pass http://dashboard_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Set
TRUST_PROXY=trueon the collector (and only when it is reachable solely through this proxy). Without it the dashboard login rate limit and client-IP handling key on the proxy's address instead of the real client, so one noisy client can lock everyone out of login.
Threat model & security boundaries
| Threat Vector | Code Grounding & Vulnerability | External Mitigation Required |
|---|---|---|
| Shared Password Compromise | dashboard-auth.ts uses a single static DASHBOARD_PASSWORD. If leaked, an attacker has full access to all projects, traces, AI calls, and key creation. | Enforce MFA and SSO at perimeter (Cloudflare Access / OIDC). Isolate dashboard to corporate IP/VPN/Identity boundaries. |
| Project Key Escalation | Any authenticated dashboard session can call POST /internal/projects/:id/keys or DELETE /internal/projects/:id/keys/:keyId. | Perimeter layer must restrict access to trusted staff only. Audit proxy logs for calls to /internal/projects/*/keys. |
| Ingest Denial of Service / Storage Exhaustion | /v1/* accepts large telemetry payloads (traces, pprof profiles, replays). An attacker with an ingest key could flood S3/Postgres storage. | Configure reverse proxy client_max_body_size 10m and rate-limiting (limit_req_zone) on /v1/*. Set S3 lifecycle expiration to 72 hours. |
| MCP Secret Exposure & Misconfiguration | @obsunified/mcp-server requires OBS_DASHBOARD_TOKEN (or OBS_SESSION_COOKIE) to read from /internal/*. It does not support Cloudflare Access service token headers. | Run MCP inside the trusted network boundary. Do not expose /internal/* publicly under the assumption that Cloudflare Access service tokens will work without proxy translation. |
Secrets management checklist
DASHBOARD_PASSWORD: High-entropy secret stored in your secret manager.DASHBOARD_TOKEN: Distinct high-entropy bearer token for programmatic read/admin access (@obsunified/mcp-serverviaOBS_DASHBOARD_TOKENand@obsunified/cliviaOBS_ADMIN_TOKEN).INGEST_KEY: Rotatable write key provisioned per project in the collector database.cookie_secret(oauth2-proxy): 16, 24, or 32-byte base64 string for AES cookie encryption.- Investigation MCP: Store
OBS_DASHBOARD_TOKEN(matchingDASHBOARD_TOKEN, orOBS_SESSION_COOKIE) securely and route MCP connections directly to the internal collector port.
Operational validation
- Verify
/healthis public:# On public ingest origin (Cloudflare topology): curl -i https://ingest.example.com/health # On dashboard origin (with Cloudflare Access bypass policy): curl -i https://obs.example.com/health # Or on single-domain reverse proxy (Nginx / Caddy): curl -i https://obs.company.com/health # Expected: 200 OK, JSON containing { status: "ok", serverTimeMs: ... } - Verify ingest authentication at collector:
# Missing API key (rejected by collector ingest auth): curl -i -X POST https://ingest.example.com/v1/traces \ -H "Content-Type: application/json" -d '{"resourceSpans":[]}' # Expected: 401 Unauthorized # Valid API key: curl -i -X POST https://ingest.example.com/v1/traces \ -H "Authorization: Bearer <INGEST_KEY>" \ -H "Content-Type: application/json" -d '{"resourceSpans":[]}' # Expected: 200 OK - Verify unauthenticated dashboard redirects to SSO:
curl -i https://obs.example.com/internal/telemetry/overview # Expected: 302 Redirect to IdP / 401 Unauthorized - Verify authenticated dashboard session with project scoping:
# With SSO session but without obs_session cookie: curl -i https://obs.example.com/internal/telemetry/overview \ -H "Cookie: _oauth2_proxy=<OAUTH_COOKIE>" # Expected: 401 Unauthorized from collector # With both SSO session and obs_session cookie: curl -i https://obs.example.com/internal/telemetry/overview \ -H "Cookie: _oauth2_proxy=<OAUTH_COOKIE>; obs_session=<OBS_SESSION>" \ -H "X-Project-Id: default" # Expected: 200 OK - Verify programmatic MCP bearer token access:
curl -i https://obs.example.com/internal/telemetry/overview \ -H "Authorization: Bearer <DASHBOARD_TOKEN>" \ -H "X-Project-Id: default" # Expected: 200 OK # Local / internal MCP host connectivity: export OBS_COLLECTOR_URL="http://127.0.0.1:8790" export OBS_DASHBOARD_TOKEN="<VALID_DASHBOARD_TOKEN>" obs-unified-mcp
Production operations
Reverse proxy, Postgres tuning, storage retention, Kubernetes, backup and restore, alerts, and monitoring.
How obs-unified compares
Evidence-backed comparison of obs-unified with Datadog, Sentry, PostHog, Honeycomb, New Relic, Grafana Cloud, SigNoz, Uptrace, and HyperDX, with primary-source citations and audit status.