Provision agent API keys
Why authentication must be on
Section titled “Why authentication must be on”WP5 points five AI coding agents (Claude Code, Copilot, Cursor, Codex, Gemini)
plus the engram CLI bridge at one always-on ENGRAM server over
MCP_TRANSPORT=streamable-http. That shared surface changes the threat model.
When AUTH_REQUIRED=false, the server reads the tenant userId from the
request body, which is fully spoofable: any local process that can reach
the port can send any userId and thereby read, write, or delete any tenant’s
memories (WP5 R1 / GAPS G1). There is no identity — only an unverified string.
The mitigation is non-negotiable for a multi-agent host:
AUTH_REQUIRED=true— every protectedtools/callmust carry a valid key.- One least-privilege API key per agent (below).
- Loopback bind (
127.0.0.1) so the port is not exposed off-host.
The server enforces this posture at boot: a multi-tenant streamable-http
server without AUTH_REQUIRED=true refuses to start in every NODE_ENV
unless the operator explicitly sets ALLOW_UNAUTHENTICATED_HTTP=true — the
trusted-network acknowledgement for a deliberately open deployment (e.g. a
loopback-bound single-operator host).
With auth on, the server injects the key’s userId over any client-supplied
userId for identity tools — the tenant is the token, not the body — and
enforces per-key scopes (memories:read, memories:write,
memories:delete; admin satisfies any). That enforcement is unit-tested in
apps/mcp-server/src/**/dispatch-auth.spec.ts. See the
agent memory server runbook for turning auth on
and binding loopback, and the
Agent Memory Contract for the userId
convention and scope grammar.
One least-privilege key per agent
Section titled “One least-privilege key per agent”Every agent gets its own key. All keys share the single tenant
userId: "qp" (per WP5 R1 and the standing convention) — the key, not the
userId, is what distinguishes agents for provenance and revocation. Give each
key only the scopes that agent actually uses.
| Agent | Key name |
userId |
Scopes |
|---|---|---|---|
| Claude Code | claude-code |
qp |
memories:read, memories:write |
| GitHub Copilot | copilot |
qp |
memories:read, memories:write |
| Cursor | cursor |
qp |
memories:read, memories:write |
| Codex | codex |
qp |
memories:read, memories:write |
| Gemini | gemini |
qp |
memories:read, memories:write |
CLI bridge (engram) |
cli-bridge |
qp |
memories:read, memories:write |
Delete is opt-in, default-deny. The table above deliberately omits
memories:delete. Grant memories:delete only for an agent that is
actually wired to call forget / delete_memory; omit it for every other
agent. A read+write key cannot delete, so an agent that never deletes cannot be
tricked (or bugged) into destroying memories. Never grant the admin scope to
an agent key — admin satisfies every scope check and is meant for operators
only.
ENGRAM_AGENTis attribution-only — it is not a substitute for distinct keys. The CLI bridge’sENGRAM_AGENTlabel (packages/agent-bridge/src/config.ts) is an unauthenticated, self-declared provenance string: any process can claim any label, and it never enters an authorization decision. Verified per-agent attribution comes from the key — the server stamps the authenticated key’s id intoMemoryAudit.actorIdon every audited mutation. With one shared key, every agent’s operations collapse into a single actor: you can neither tell agents apart in the audit trail nor revoke one agent without cutting off all of them. Distinct keys per agent are required for per-agent authz, attribution, and revocation.
Minting a key
Section titled “Minting a key”Keys are minted with the admin MCP tool create_api_key. There is no REST
endpoint — create_api_key is invoked as an MCP tools/call, and the call
must present the admin credential (adminToken, see below). Two practical ways
to make the call:
- MCP Inspector — run
pnpm inspectorand point it at the server (http://127.0.0.1:3000/mcp), then invokecreate_api_keyfrom the tool UI. - An authenticated admin MCP client — any client that speaks MCP
Streamable-HTTP and can send the
tools/callbelow.
The tool input (validated by the Zod schema in
apps/mcp-server/src/api-keys/dto/create-api-key.dto.ts) takes userId,
adminToken (≥16 chars, equals MCP_ADMIN_TOKEN), name (1–100 chars),
scopes (1–10 of memories:read | memories:write | memories:delete |
admin), and optional expiresInDays (1–3650). The JSON-RPC envelope:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "create_api_key", "arguments": { "userId": "qp", "adminToken": "<MCP_ADMIN_TOKEN>", "name": "claude-code", "scopes": ["memories:read", "memories:write"], "expiresInDays": 90 } }}The response returns the plaintext key exactly once (format eng_…)
alongside { id, prefix, name, scopes, expiresAt, createdAt, warning }. The
server stores only a hash — it can never show the plaintext again. Copy the
eng_… value immediately into the target agent’s config (next section); if
you lose it, revoke and re-mint. Repeat the call once per agent, changing only
name (and scopes).
Provisioning in one command
Section titled “Provisioning in one command”Instead of repeating the manual create_api_key call per agent, the
provision-agent-keys CLI mints the whole fleet in one pass. It uses
ApiKeysService directly against the database — run it on the server host
(with the server’s .env); it needs neither a running server nor
MCP_ADMIN_TOKEN:
pnpm --filter mcp-server provision-agent-keys -- \ --agents claude-code,copilot,cursor,codex,gemini,cli-bridge --user qpFor each agent name it mints one distinct key labelled agent:<name>
under the single shared tenant (--user, normally qp — distinct keys,
one memory pool), prints the eng_ plaintext once with ready-to-paste
ENGRAM_API_KEY / Authorization: Bearer snippets (next section), and
refuses admin outright. Options:
--scopes— defaultmemories:read,memories:write,memories:delete. To follow the delete-is-opt-in table above, pass--scopes memories:read,memories:writefor agents that never delete.--expires-in <duration>— key lifetime, e.g.90d,12w,1y(default: no expiry).--rotate— revoke-and-replace. Without it, an agent whoseagent:<name>key is still active is reported as already provisioned and skipped — the plaintext is not recoverable, and the CLI never silently re-mints — so re-running the command is safe.
Where each key goes
Section titled “Where each key goes”Each agent authenticates by sending its own plaintext key on every request as an HTTP header:
Authorization: Bearer eng_<the-key-for-this-agent>Wire it in one of two equivalent ways, per agent:
- MCP config header — set the
Authorization: Bearer <key>header on theengramserver entry in that agent’s MCP client configuration. - Environment variable — set
ENGRAM_API_KEY=eng_…; theengramCLI bridge and hook wrappers read it and attach the Bearer header for you.
The exact config file and header syntax for each of the five agents is in
Agent memory client wiring. Never commit a
key to git — no eng_… value belongs in a tracked file, a checked-in
.mcp.json, or a commit message. Keep keys in per-agent local config or an
untracked env file.
MCP_ADMIN_TOKEN is admin-only
Section titled “MCP_ADMIN_TOKEN is admin-only”MCP_ADMIN_TOKEN is not an agent key and must never be handed to an
agent. It guards the destructive/admin tools (reindex_*, consolidate,
create_api_key, revoke_api_key) via a constant-time comparison. A leaked
admin token lets any holder mint keys for any tenant and run admin operations —
it is the most sensitive secret in this system.
- Give agents only their scoped
eng_…key. Give no agent the admin token. - Store
MCP_ADMIN_TOKENonly where an operator mints/rotates keys (the server env / operator shell), never in an agent config.
Rotating a key
Section titled “Rotating a key”Rotation is revoke, then re-mint — there is no in-place update. Use it on a schedule, on suspected exposure, or when an agent is retired.
- Find the key id: call
list_api_keyswith{ "userId": "qp" }and read theidof the key whosename/prefixyou are rotating. - Revoke it: call
revoke_api_keywith{ "userId": "qp", "keyId": "<id>" }. The oldeng_…value stops working immediately. - Re-mint: run the
create_api_keytools/callabove with the samenameandscopesto get a fresheng_…(shown once). - Update that agent’s config (the
Authorizationheader orENGRAM_API_KEY) with the new value, then confirm the agent can stillrecall.
list_api_keys and revoke_api_key are admin-guarded like create_api_key;
they require the admin credential and act by userId (and keyId for revoke).
Verification checklist
Section titled “Verification checklist”The automated base checks — health 200, the MCP initialize handshake,
tools/list, and that an unauthenticated protected tools/call returns
401 when AUTH_REQUIRED=true — are in
scripts/verify-engram-server.sh.
Run that first.
The scope and identity checks below are also automated by the same script:
mint a read-only key (scopes ["memories:read"]) and a read+write key,
then pass them in:
ENGRAM_READONLY_KEY=eng_… ENGRAM_WRITE_KEY=eng_… \ ./scripts/verify-engram-server.shENGRAM_READONLY_KEY drives the read-ok and write-denied checks;
ENGRAM_WRITE_KEY drives the spoofed-userId check (it writes one
short-term probe memory that self-expires after 60 seconds). Without the
variables the script skips these checks with a notice. A presented key is
validated and scoped even on an auth-off server, so the scoped checks work
there too. To verify by hand instead — or to understand exactly what the
script asserts — the checklist is:
- Read-only key can recall. A
tools/callforrecallwith the read-only key’sAuthorization: Bearerheader succeeds. - Read-only key cannot remember. The same key calling
rememberis rejected with a scope error (403-equivalent): the missingmemories:writescope is denied, no memory is written. - Spoofed body
userIdis ignored. Send aremember/recallwhose body setsuserIdto some other value (e.g."attacker"); the server injects the key’suserId(qp) and the operation acts onqp, not the spoofed value. The token wins over the body. - No key is
401. A protectedtools/callsent with noAuthorizationheader returns401(auth is on).
If any check fails, do not distribute keys until AUTH_REQUIRED=true, loopback
bind, and the per-agent keys above are all in place.