Auth & multi-tenancy
Engram’s authorization model rests on one principle: the tenant is the
token, not the request body. Without authentication, userId is just a
string the client typed — any process that can reach the port can read,
write, or delete any tenant’s memories by claiming any id. Everything on this
page exists to close that hole without making the local, single-user case
miserable.
Where auth applies
Section titled “Where auth applies”Authentication is enforced on the streamable-http transport only. stdio
is a private pipe between one client process and one server process — there
is no network surface and no second tenant, so AUTH_REQUIRED has no effect
there. The moment memories are served over HTTP to potentially many callers,
identity becomes load-bearing.
The boot fail-safe
Section titled “The boot fail-safe”The dangerous configuration — multi-tenant profile, HTTP transport, no
auth — is rejected at boot, not discovered in production.
assertHttpAuthPosture() (apps/mcp-server/src/http-auth-posture.ts)
refuses to start when all three hold:
- the active profile is multi-tenant (
standard), MCP_TRANSPORT=streamable-http,AUTH_REQUIREDis false andALLOW_UNAUTHENTICATED_HTTPis not set.
Refusing to start: multi-tenant streamable-http without AUTH_REQUIRED=true. This would serve all tenants unauthenticated with a client-controlled userId. Set AUTH_REQUIRED=true (recommended), or set ALLOW_UNAUTHENTICATED_HTTP=true to acknowledge a trusted-network deployment.
Deliberately, this applies in every NODE_ENV — development included.
The tenant boundary does not depend on an environment label, so running
unauthenticated must always be an explicit operator acknowledgement
(ALLOW_UNAUTHENTICATED_HTTP=true), never an accident of
NODE_ENV=development. Single-tenant profiles are exempt: with one tenant
there is nothing to cross into.
Credentials: JWTs and per-agent API keys
Section titled “Credentials: JWTs and per-agent API keys”With AUTH_REQUIRED=true (which requires a ≥32-char JWT_SECRET), every
/mcp call passes through McpAuthMiddleware, presenting either:
- a session JWT (issued via OAuth login; lifetime
JWT_EXPIRES_IN, default 7 d), or - an API key —
Authorization: Bearer eng_…orX-API-Key.
API keys are the workhorse for agents. Each key
(apps/mcp-server/src/api-keys/) is 24 random bytes, base64url-encoded,
prefixed eng_; the server stores only a SHA-256 hash plus a display prefix,
so plaintext is shown exactly once at mint time. Keys carry scopes, optional
expiry, revocation, and lastUsedAt bookkeeping.
The design intent is one least-privilege key per agent, all sharing one
tenant userId: the key (not the userId) is the unit of attribution and
revocation. The provision-agent-keys CLI mints a whole fleet in one pass
and refuses to mint agent keys with the admin scope. Operational guide:
Provision agent API keys.
A request with an invalid credential is always rejected — never downgraded
to anonymous. A request with no credential on a protected tool gets a 401
JSON-RPC error.
Three auth modes, per tool
Section titled “Three auth modes, per tool”Every tool in the manifest declares a ToolAuthMode
(packages/core/src/mcp/tools/index.ts):
| Mode | Contract |
|---|---|
identity (default) |
The verified credential’s userId is injected over any client-supplied value; scope checks apply |
admin |
Not gated by user identity — the tool input carries an adminToken compared in constant time against MCP_ADMIN_TOKEN |
public |
Callable unauthenticated (only ping) |
Two separate trust chains is a deliberate choice: tenant-facing tools bind to
who you are (key → userId → your rows), while maintenance tools
(reindex_*, consolidate_corpus, import_agent_memory, create_api_key)
bind to possession of the operator secret. A leaked agent key therefore
cannot reindex, import, or mint keys; conversely MCP_ADMIN_TOKEN never
belongs in an agent config.
Scopes
Section titled “Scopes”Identity tools declare a requiredScope in the tool manifest:
memories:read for reads, memories:write for mutations, memories:delete
for deletions; the admin scope satisfies any check. Scopes are enforced at
dispatch, so a read-only key calling remember fails before any write. This
is what makes “delete is opt-in” a policy you can actually enforce per agent
rather than a convention you hope agents follow.
Delegation: the audited exception
Section titled “Delegation: the audited exception”Sometimes an operator key must act on another tenant (support, migration,
the web console). resolveActingUserId() allows it under three simultaneous
conditions: the tool is marked delegable in the manifest, the caller holds
the admin scope, and an explicit userId is passed. Everyone else is
pinned to their verified userId — and every delegated call is recorded as
such in the audit trail (MemoryAudit.delegated, with the acting key as
actorId). Non-delegable tools (credential management above all) pin even
admins.
Isolation below the auth layer
Section titled “Isolation below the auth layer”Authorization would be fragile if row access were enforced only at the
boundary, so the storage layers repeat the constraint: every Memory,
ApiKey, MemoryAudit, MemoryLink, and MemoryImportSource row carries
userId, every service query filters on it, and the vector store embeds
userId in its
search filter.
STM rows carry userId (and org) like every other memory row. An optional
organizationId adds a second grouping level for multi-org deployments; the
standard profile’s Postgres-backed rate limiter (rate_limit_counters)
buckets by key, user, org, and IP (RATE_LIMIT_*, see the
configuration guide).
Turning it on
Section titled “Turning it on”The operational sequence — generating JWT_SECRET, setting the flags,
minting keys, verifying enforcement — is in
Enable authentication.