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 agent | A third-party assistant | |
|---|---|---|
| Who runs it | You, on your machine | A company, on theirs |
| Where the secret sits | Your keychain | Their server |
| Revoking it | Rotate the token | Must be possible per client, instantly |
| Scope needed | Everything — it is you | As little as the task requires |
| Right mechanism | Static bearer token | OAuth 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.
Four rules that keep this honest
- Codes expire in minutes; tokens in days. A code is a handshake, not a credential.
- One code, one use. Delete it on redemption. A replayed code is an attack, not a retry.
- Scopes are enforced at the tool, not just at the door. Middleware says who you are; the write tool checks for
write. A read-only client that can calllog_weightis not read-only. - Revocation must actually work. Delete the row, and check expiry on every call rather than trusting the token's own claims. This is precisely why tokens live in D1 instead of being self-contained JWTs — you trade a database read for the ability to say no immediately.
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