Skip to content

Security Model

Kritarth-Dandapat edited this page Jul 10, 2026 · 1 revision

Security Model

Every request to the Worker needs a valid bearer token. Unauthorized requests are rejected before any KV read happens, at two separate layers. This page covers the token scopes, how comparisons are done safely, why one "list" tool is write-gated instead of read-gated, and the trust boundary that keeps the curated context from ever being agent-writable.

Two tokens, two scopes

There are exactly two secrets: READ_TOKEN and WRITE_TOKEN. Each incoming bearer token is checked against both, independently, and maps to a Set<"read" | "write"> of granted scopes (scopesForToken in src/mcp/auth.ts). A token can grant zero, one, or (in the misconfiguration case of READ_TOKEN === WRITE_TOKEN) both scopes — the code represents that honestly rather than forcing a single winner.

Scope Token Tools
read READ_TOKEN get_context, list_sections, get_meta
write WRITE_TOKEN append_journal, list_journal

Give each of the servers/agents that only need to leave journal notes the write token only, and give whatever reads context for you (your own Claude Code/Desktop/browser sessions) the read token. A read-token leak exposes your curated context; a write-token leak lets someone add journal notes. Because promotion is manual (see below), a write-token compromise cannot silently corrupt your memory — you would see the poisoned note before it became permanent.

Two-layer auth check

Layer 1 — Worker fetch handler (src/worker.ts). Before any MCP dispatch or KV access, the Worker extracts the Authorization: Bearer <token> header and computes scopesForToken. If the token matches neither secret at all (scopes.size === 0), the Worker returns a bare 401 immediately — MCP dispatch never starts, KV is never touched.

Layer 2 — per-tool handler (src/mcp/tools.ts). Every tool handler calls authorize(token, toolName, env) before doing anything else. This catches the case of a validly-authenticated but wrong-scope token — e.g. a READ_TOKEN calling append_journal — and returns a normal JSON-RPC tool error rather than a second HTTP-level rejection, since MCP dispatch has legitimately started by the time a specific tool is invoked.

This split exists because "no token at all" and "a token, but the wrong one for this specific tool" are different failure modes that show up at different points in the request lifecycle, and the Worker-level check is what satisfies HANDOFF.md's requirement that unauthorized requests never reach KV.

Constant-time comparison

Token comparisons use constantTimeEqual in src/mcp/auth.ts, not a plain === or early-exit string compare. Cloudflare Workers doesn't expose Node's crypto.timingSafeEqual, and relying on Workers' own crypto.subtle.timingSafeEqual extension would make this module untestable under plain Node/vitest (Node's Web Crypto doesn't implement it). Instead, both inputs are SHA-256-hashed first (crypto.subtle.digest, standard Web Crypto — available in both Workers and Node >= 20) to a fixed 32-byte digest, then compared byte-by-byte with an XOR accumulator and no early exit. Hashing first also removes a subtler timing leak: comparing raw strings of different lengths directly branches on length before it branches on content, and this sidesteps that by always comparing fixed-length digests.

scopesForToken also always checks the token against both secrets, even after a match — no early return after finding the read match, for instance — so response timing can't be used to infer which secret a wrong token was "closer" to.

Why list_journal is write-gated, not read-gated

list_journal reads back journal entries; by naming convention it looks like it belongs with the other "list"/read tools. It is deliberately grouped with WRITE_TOOLS instead, because it exposes raw, unpromoted notes — text an agent wrote that the owner has not yet reviewed or approved. That is a higher sensitivity level than the curated context the read tools serve, which has already passed through the human promotion gate. This was an open question in HANDOFF.md (§12.2) resolved in favor of the more restrictive option.

The promotion trust boundary

This is the load-bearing security property of the whole project, not just a convention:

  • No MCP tool can write to a curated key (context:full:md, section:*:md, meta:json). The only function that writes those keys is the build step (scripts/generate-artifacts.ts), run locally by the owner and pushed by hand with wrangler kv bulk put.
  • append_journal is implemented to only ever call appendJournalEntry in src/kv.ts, and that function only ever touches journal:* keys. There is no code path from an agent's tool call to a curated key.
  • scripts/promote.ts reads the journal and lets the owner decide what to keep, but it does not auto-edit content/*.md. The owner edits those files by hand, then reruns the build.

Put together: an attacker who obtains the write token can add journal noise, but cannot make that noise permanent without the owner personally reading it and choosing to copy it into content/. A compromised curated context would require either the owner's own machine being compromised, or their Cloudflare account credentials — not just a leaked MCP token. See Design-Decisions (D2, D4) for the reasoning behind choosing this design over an auto-extracting memory tool, and Architecture for where each of these pieces sits in the pipeline.

Other hygiene

  • Token-gated responses set Cache-Control: private, max-age=60 — short-lived and never shared across a proxy or between different callers' tokens.
  • No personal data appears in URLs, query strings, logs, or cache keys — the only owner-identifying data in transit is the bearer token itself and the response bodies, which are only ever returned to an already-authorized caller.
  • Secrets (READ_TOKEN, WRITE_TOKEN, KV namespace IDs) live in Cloudflare (wrangler secret put), never in the repo. wrangler.toml (the real one) and .dev.vars are gitignored; only wrangler.toml.example is committed.

Clone this wiki locally