MCP Authentication & Bio-ID Identity

Overview

JCI-MCP uses Bio-ID as its authorization server. All identity — bioId, orgSlug, and roles — flows from a Bio-ID JWT. That JWT drives which JCI role a user gets, which pipeline audiences they can access, and how jci-brain scopes returned data.

There are two credential paths:

PathCredentialUse case
OAuth popupBio-ID Bearer JWT (auto-managed)claude.ai, Cursor, Windsurf, any MCP-OAuth client
JCI API keyjci_* static keyClaude Code stdio, CLI, programmatic tools

Hostnames — which URL actually exists

Verified 2026-10-02 against DNS and each host's /health and /.well-known/oauth-protected-resource.

URLStatusWhat it is
https://mcp.askjefferson.comlive — use thisPublic entry, served by the JCI plane (Kubernetes, 2 pods, Envoy Gateway; Cloudflare-proxied). Publishes the OAuth discovery documents; POST / and POST /mcp are the MCP transport; /mcp/jci-mcp/<user> is the URL-username path
https://jci-mcp.tawa.proliveAlias of mcp.askjefferson.com: A record straight to the plane load balancer (DNS-only), same pods. Discovery documents name this host when you arrive here
https://jefferson.daryx.com/mcp/jci-mcp/<user>live, legacyOld path-prefix URL, not on the plane. No .well-known documents, so no OAuth discovery

Where the code runs, and how a change reaches it

Since the 2026-09-26 cutover (insureco/jci-plane, docs/cutover-jci-mcp.md), public MCP traffic runs on the plane, not on jefferson. jefferson keeps a PM2 copy of jci-mcp, and it is still part of the path:

./deploy.sh  →  jefferson PM2 jci-mcp (:9002)
             →  plane-image-sync timer on jefferson (every 5 min, jci-plane#48)
                builds registry.digitalocean.com/insureco/jci-mcp:<sha>, merges the tag into jci-plane
             →  Argo CD rolls the plane
  • A green deploy.sh proves jefferson only. The change is live for clients when the public health check shows its commit: curl -s https://mcp.askjefferson.com/health | grep -o '"commit":"[^"]*"'
  • curl localhost:9002/health on jefferson is the break-glass copy, not what clients hit.
  • Never stop PM2 jci-mcp on jefferson. It is the commit plane-image-sync ships, and the standing break-glass if the plane fails (jci-plane docs/runbook-break-glass.md, decision 2026-09-27).

Correction (2026-10-02): this table said jci-mcp.tawa.pro was "an A record straight to jefferson … same nginx proxy to the same process". That stopped being true at the plane cutover. Acting on it, an agent verified a fix on jefferson's localhost:9002, called it live while clients still ran the previous commit, and proposed stopping the jefferson process the deploy pipeline depends on.

Correction (2026-09-08): the Claude Code examples below pointed at https://jci-mcp.tawa.pro/mcp while that host did not exist — no DNS record, no ingress. It was brought up the same day as a true alias, so the URL now works, but mcp.askjefferson.com stays the canonical name and the one connectors are registered under. Separately, POST /mcp rejected initialize until insureco/jci-mcp#59 — a client pointed at /mcp before that fix would fail its handshake on either host.


Path 1 — OAuth (claude.ai, Cursor, Windsurf)

MCP clients that implement the MCP OAuth spec discover the authorization server automatically via two well-known endpoints that jci-mcp exposes:

GET /.well-known/oauth-protected-resource
  → { authorization_servers: ["https://bio.tawa.pro"], bearer_methods_supported: ["header"], ... }

GET /.well-known/oauth-authorization-server
  → { issuer: "https://bio.tawa.pro",
      authorization_endpoint: "https://bio.tawa.pro/oauth/authorize",
      token_endpoint: "https://bio.tawa.pro/api/oauth/token",
      code_challenge_methods_supported: ["S256"], ... }

What the client does automatically

  1. User adds the MCP server URL: https://mcp.askjefferson.com
  2. Client fetches /.well-known/oauth-protected-resource → discovers Bio-ID is the AS
  3. Client fetches Bio-ID's OAuth metadata from /.well-known/oauth-authorization-server
  4. Client opens the Bio-ID login popup (PKCE authorization code flow)
  5. User authenticates with Bio-ID
  6. Client receives and stores the access token
  7. All subsequent MCP requests carry Authorization: Bearer <bio-id-jwt>

The user does not configure any client ID or secret manually. The MCP client handles the full PKCE flow with Bio-ID directly.

Token verification in jci-mcp

On each request, jci-mcp's bioAuthMiddleware verifies the Bearer token against Bio-ID's JWKS endpoint (https://bio.tawa.pro/.well-known/jwks.json). The JWKS response is cached and refreshed automatically on key rotation. No shared secret is needed — tokens are RS256.

Request arrives with Authorization: Bearer <jwt>
  → bioAuthMiddleware verifies via JWKS
  → extracts { bioId, orgSlug, roles, email } from JWT payload
  → sets req.userContext
  → request proceeds to tool handler

Path 2 — JCI API Key (Claude Code, CLI, programmatic)

JCI API keys are generated from the askJefferson admin portal. They carry a pre-assigned role and pipeline audience set and are stored in jci-brain as SHA-256 hashes (the raw key is shown once at generation time).

Key format: jci_<12-char-prefix>_<36-char-random>

The key is sent in the Authorization header:

Authorization: Bearer jci_abc123456789_<rest-of-key>

Or in the x-api-key header:

x-api-key: jci_abc123456789_<rest-of-key>

jci-brain detects the jci_ prefix and routes to API key verification instead of JWT verification. The key is hashed on arrival and looked up against the jci_api_keys collection. Each key stores:

  • bio_id — the Bio-ID of the key owner
  • role — the JCI role (developer, devops, business, admin)
  • pipeline_audiences — the audiences this key can access
  • persona_id — which persona (context profile) this key belongs to
  • last_used — updated on each successful verification

Identity Flow: Bio-ID JWT → JCI Context

From a Bio-ID JWT

decoded.sub (or decoded.bioId)  →  jciUser.bio_id
decoded.orgSlug                 →  jciUser.org_slug  (company scoping)
decoded.roles[]                 →  mapped to JCI role (see table below)
decoded.email                   →  jciUser.email

After JWT verification, jci-brain looks up the user in jci_users by bio_id to find their active persona. The persona carries the definitive role and pipeline_audiences for this session. If no persona exists yet, defaults to role: business / pipeline_audiences: ['business', 'public'].

From a JCI API key

Role and pipeline audiences are stored directly on the key record — no persona lookup needed. Identity is the key's bio_id.


Role Mapping

jci admin means platform admin, and nothing weaker (insureco/jci-brain#396, #400). Platform admin uses Bio-ID's own definition: the JWT roles claim includes super_admin (iec-bio lib/is-platform-admin.ts). roles is the user's platform role list. An org admin's role is in orgRoles and never counts, and a bare admin in roles is a Bio-ID product role, not an admin.

Bio-ID tokenJCI rolePipeline audiences
super_admin in roles (platform admin)adminbusiness, developer, infra, internal, public
developer in rolesdeveloperdeveloper, public
devops in rolesdevopsdeveloper, infra, public
anything else, including admin in roles and every org rolebusinessbusiness, public

The mapping is performed by jci-brain's authMiddleware._verifyBioJwt (bioRolesToJciRole). Note: for Bio-ID JWT logins, persona_id is set to null, because the middleware does not look up a persona on JWT authentication.

One invariant, on every auth path

AuthMiddleware._attach applies capRole after the verifier runs, whatever the credential: role admin and the internal audience require the owner or a platform admin. Any other caller carrying them is served as business / business, public. That covers:

  • a JCI API key whose stored role is admin, when its owner is not the owner or a platform admin (the stored record is not rewritten, and it regains nothing);
  • a persona role on the legacy URL path.

A person also cannot create an admin persona unless they are a platform admin (POST /api/identity/me/personas → 403). A personal key copies its persona's role.

Service-identity API keys are an explicit allowlist (src/utils/serviceIdentity.js: SERVICE-JCI-MCP, SERVICE-ADMIN-CHATBOT). They keep their stored role and audiences, and pass platform gates only if an operator hand-set platform_admin on the key (jci-brain#407). Only an allow-listed service may name another person in x-bio-id (jci-brain#403).

Header-trusted routes are gated (jci-brain#402)

/api/intelligence/{tasks,review,schedule,calendar,emails,team,outbound} and the general /api/intelligence router sit behind trustedCaller. It accepts three kinds of caller:

  • iec-portal's shared secret, on /tasks only;
  • a genuinely local caller with no jci_ credential (loopback, closable with INTELLIGENCE_ROUTES_ALLOW_LOCAL=false);
  • any credential authenticate() accepts.

A person's identity headers and user_id-style parameters are rebuilt from req.jciUser, so a person cannot name anyone else. The URL-username path (/mcp/jci-brain/:username) was removed in jci-brain#403. Use a Bio-ID JWT or a JCI API key.

Gates use isPlatformAdmin, never role === 'admin'

Every gate over other people's data (the roster, identity edits, /api/admin/*, service keys, dev projects, the unfiltered pipeline view) checks isPlatformAdmin(req.jciUser) (src/utils/platformAdmin.js): is_owner, or platform_admin from a verified JWT. A role is a label a caller can arrive with, so it is never the gate. Org admins keep org-scoped powers only.


Pipeline Audience Scoping

Every data record in jci-brain carries one or more pipeline_audiences tags. Queries are filtered to only return records whose audience overlaps with the authenticated user's pipeline_audiences.

JCI roleSees
admin (platform admin only)All data (business + developer + infra + internal + public)
developerDeveloper + public data
devopsDeveloper + infra + public data
businessBusiness + public data

This means a business user querying pipeline logs will only see records tagged business or public — they cannot see developer build logs or infra metrics.


Environment Variables

VariableRequired byPurpose
BIO_ID_URLjci-mcp, jci-brainBio-ID base URL. Default: https://bio.tawa.pro
MCP_SERVER_URLjci-mcpIncluded in oauth-protected-resource response as resource. The plane answers with the host the request arrived on (https://mcp.askjefferson.com or https://jci-mcp.tawa.pro)
BIO_AUTH_REQUIREDjci-mcpSet true to reject requests without a valid JWT (disables legacy fallback)
OWNER_BIO_IDjci-brainbio_id that gets is_owner: true (admin bypass)

Client Configuration Examples

claude.ai (web)

  1. Go to Settings → Integrations → Add MCP Server
  2. Enter the server URL: https://mcp.askjefferson.com
  3. Claude.ai detects the Bio-ID OAuth requirement and shows a login popup
  4. Authenticate with your Bio-ID account
  5. Done — claude.ai manages token refresh automatically

Cursor / Windsurf

In .cursor/mcp.json or the MCP settings panel:

{
  "mcpServers": {
    "jci-mcp": {
      "url": "https://mcp.askjefferson.com",
      "transport": "http"
    }
  }
}

Cursor will pop the Bio-ID OAuth flow on first connection.

Claude Code (stdio with JCI API key)

In ~/.claude/settings.json or the project .claude/settings.json:

{
  "mcpServers": {
    "jci-mcp": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://mcp.askjefferson.com/mcp"],
      "env": {
        "BEARER_TOKEN": "jci_your_api_key_here"
      }
    }
  }
}

Or using environment headers with mcp-remote:

MCP_SERVER_HEADERS='{"Authorization":"Bearer jci_your_key"}' \
  npx mcp-remote https://mcp.askjefferson.com

Claude Code (remote OAuth — claude.ai SDK)

Claude Code in remote mode will follow the same OAuth popup flow as claude.ai when the server URL is used directly without a pre-configured key:

{
  "mcpServers": {
    "jci-mcp": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://mcp.askjefferson.com/mcp"]
    }
  }
}

Authentication Priority Order

jci-brain's authMiddleware checks credentials in this order:

  1. Bearer JWT starting with jci_ → JCI API key path
  2. Bearer JWT (other) → Bio-ID RS256 JWT verification
  3. x-api-key header starting with jci_ → JCI API key path
  4. URL-based username (removed in jci-brain#403)
  5. Dev headers (x-bio-id, x-role) → localhost only, development mode

In production with BIO_AUTH_REQUIRED=true, steps 3-5 are rejected with 401.


Key Facts

  • Bio-ID tokens are RS256-signed. jci-mcp and jci-brain verify via JWKS — no shared secret.
  • BIO_ID_URL defaults to https://bio.tawa.pro — no env var needed in standard deployments.
  • JCI API keys are hashed (SHA-256) in storage — the raw key cannot be recovered.
  • orgSlug from the JWT becomes the company_id for data scoping in jci-brain.
  • The iecHash field on InsureBio entities is the Vault chain address for insurance entities — it is a separate concept from JCI user identity (bioId). Do not conflate the two.
  • Personas allow one bio_id to carry different roles in different contexts (e.g., a user who acts as both developer and business). The default persona is used unless a specific persona_id is passed.

Common Mistakes

  • Putting a Bio-ID access token in the x-api-key header → only jci_* keys are accepted there
  • Confusing iecHash (InsureBio Vault entity address) with bioId (JCI user identity)
  • Expecting orgSlug in the JWT without the user having an org in Bio-ID
  • Using BIO_AUTH_REQUIRED=true in dev before you have a JCI API key configured
  • Adding internalDependencies: bio-id in catalog-info.yaml to enable OAuth — wrong; use spec.auth: mode: sso

Last updated: October 6, 2026