Two doors, one room

Your own agent and a commercial chat assistant are not the same kind of caller, and pretending they are produces either pointless ceremony or a genuine security hole. Health OS gives them different doors into the same room.

Why not one mechanism for both

Your own agentA third-party assistant
Who runs itYou, on your machineA company, on theirs
Where the secret sitsYour keychainTheir server
Revoking itRotate the tokenMust be possible per client, instantly
Scope neededEverything — it is youAs little as the task requires
Right mechanismStatic bearer tokenOAuth 2.1, scoped

Give a third party your master token and you have handed a company a permanent, unscoped key to your health data with no way to revoke it short of breaking your own tooling. Make your own scripts do a full OAuth dance and you have bought a great deal of machinery for a problem you do not have.

The static token, done properly

async function checkAuth(request, env) {
  const header = request.headers.get("Authorization") || "";
  const token  = header.startsWith("Bearer ") ? header.slice(7) : null;
  if (!token) return { ok: false };

  // 1) your own key
  if (env.API_TOKEN && timingSafeEqual(token, env.API_TOKEN)) {
    return { ok: true, kind: "owner", scopes: ["read", "write"] };
  }

  // 2) an OAuth access token issued to a client
  const row = await env.DB.prepare(
    "SELECT client_id, scopes, expires_utc FROM oauth_tokens WHERE token = ?1"
  ).bind(token).first();

  if (row && row.expires_utc > new Date().toISOString()) {
    return { ok: true, kind: "client", client: row.client_id, scopes: row.scopes.split(" ") };
  }

  return { ok: false };
}

Store the token as a Cloudflare secret (wrangler secret put API_TOKEN), never in the repo, and compare it in constant time — a plain === on a secret leaks its length and, in theory, its prefix through timing.

The OAuth path, in four endpoints

Enough for a chat assistant to connect as a client, and not a byte more:

functions/oauth/
  authorize.js     → GET  — you approve the client, in your browser
  token.js         → POST — code ↔ access token (PKCE verified here)
  register.js      → POST — dynamic client registration
  revoke.js        → POST — kill one client's access, now

The flow, stripped of jargon: the client sends you to /oauth/authorize; you see who is asking and what for, and approve; it gets a short-lived code; it exchanges that code for an access token at /oauth/token, proving it is the same client that started (PKCE). Three tables — oauth_clients, oauth_codes, oauth_tokens — hold the state.

PKCE is not optional and is not hard. The client invents a random secret, sends its hash when it asks for the code, and the secret itself when it redeems it. Without it, anyone who intercepts the code can spend it. Ten lines, closes the main hole in the whole flow.

Four rules that keep this honest

What to check before you connect anything real

# 1 · no credential → 401 on a private route
curl -s -o /dev/null -w '%{http_code}\n' https://your-site/api/health/weight

# 2 · a public route still works with no credential
curl -s https://your-site/api/health/wellbeing | jq '.redacted'

# 3 · a revoked token is dead immediately
curl -s -H "Authorization: Bearer $OLD" https://your-site/api/health/weight
Run those three after every change to middleware. Auth is the one part of this system where a regression is silent, permanent and not yours to discover first.

Where next