-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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.
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.
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.
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 withwrangler kv bulk put. -
append_journalis implemented to only ever callappendJournalEntryinsrc/kv.ts, and that function only ever touchesjournal:*keys. There is no code path from an agent's tool call to a curated key. -
scripts/promote.tsreads the journal and lets the owner decide what to keep, but it does not auto-editcontent/*.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.
- 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.varsare gitignored; onlywrangler.toml.exampleis committed.