Auth & Isolation
engram enforces per-actor memory isolation backed by OIDC bearer tokens. When authentication is enabled, each caller sees and mutates only their own records. When it is disabled, all callers share a single anonymous bucket.
Enabling authentication
Section titled “Enabling authentication”Set --oidc-issuer (or its env equivalent ENGRAM_OIDC_ISSUER) to the OIDC
issuer URL. This is the only configuration required to enable bearer-token
enforcement.
engram serve \ --oidc-issuer https://idp.example/application/o/engram/ \ --oidc-audience engramThe MCP bearer-token lane’s serve flags that have both a --flag and an ENGRAM_* env equivalent (the web-UI lane’s flags are in the console section below):
| Flag | Env | Default |
|---|---|---|
--listen-addr |
ENGRAM_LISTEN_ADDR |
:8080 |
--oidc-issuer |
ENGRAM_OIDC_ISSUER |
(unset — auth disabled) |
--oidc-audience |
ENGRAM_OIDC_AUDIENCE |
(unset — audience not checked) |
--oidc-resource-metadata |
ENGRAM_OIDC_RESOURCE_METADATA |
(unset) |
| (env-only) | ENGRAM_MCP_RESOURCE_URL |
(unset) |
Storage and embedding are configured via env-only variables (ENGRAM_QDRANT_ADDR,
ENGRAM_QDRANT_COLLECTION, ENGRAM_OPENAI_BASE_URL, ENGRAM_OPENAI_API_KEY,
ENGRAM_EMBED_MODEL, ENGRAM_EMBED_DIM) — these do not have --flag equivalents.
Protected-resource metadata (RFC 9728)
Section titled “Protected-resource metadata (RFC 9728)”engram serves the RFC 9728 OAuth protected-resource metadata document,
unauthenticated, at /.well-known/oauth-protected-resource and at its §3.1
path-suffix form (that path plus the resolved MCP path, e.g.
/.well-known/oauth-protected-resource/mcp). Both return 200 with
Content-Type: application/json and a byte-identical body.
The document has four fields:
resource— the public URL the MCP endpoint is reached on. WhenENGRAM_MCP_RESOURCE_URLis set, this is that value verbatim and no request header can influence it. When unset, it is derived per request fromX-Forwarded-Proto/X-Forwarded-Host/Hostand the resolved MCP path; a derived response also carriesCache-Control: no-storeso no shared cache retains a document derived from a spoofable header. A configured response carries noCache-Controlheader at all.authorization_servers— a one-element array holdingENGRAM_OIDC_ISSUER. Omitted entirely (not an empty array) when no issuer is configured.bearer_methods_supported— the constant["header"].scopes_supported— the constant["offline_access"].
ENGRAM_MCP_PATH can no longer be set to /.well-known or any path under
it — that root is reserved for these routes.
What is verified
Section titled “What is verified”On every MCP request, engram verifies:
- Signature — validated against the issuer’s JWKS endpoint
- Issuer — must match
--oidc-issuer - Expiry — expired tokens are rejected
- Audience — checked only when
--oidc-audienceis set
The verified identity is extracted from the token (email address preferred, then
username, then subject) and recorded as the record’s actor field.
No issuer configured
Section titled “No issuer configured”When --oidc-issuer is not set, validation is disabled. Every request is
accepted. The server logs a loud warning at startup so this state is never
silently open. All callers share a single anonymous bucket (owner == "").
Two auth lanes: agents vs. web console
Section titled “Two auth lanes: agents vs. web console”engram authenticates over two independent lanes, each verifying the issuer and token signature on its own (and the audience where configured):
- MCP bearer lane — agents forward an OIDC bearer token (issued to a
public PKCE client). Verified against
ENGRAM_OIDC_ISSUER; the audience is checked only whenENGRAM_OIDC_AUDIENCEis set. - Web console login lane — the operator console runs the OIDC authorization-code flow as a confidential client. It verifies ID tokens against its own issuer and pins the audience to the client ID.
The console lane is configured by these env vars (all have --flag equivalents)
and only activates when its credentials are present (or forced via
ENGRAM_UI_ENABLED=true):
| Flag | Env | Purpose |
|---|---|---|
--ui-enabled |
ENGRAM_UI_ENABLED |
"" implies-from-creds, "true" forces on, "false" hard off |
--ui-issuer |
ENGRAM_UI_ISSUER |
Console OIDC issuer — empty defaults to ENGRAM_OIDC_ISSUER |
--oidc-client-id |
ENGRAM_OIDC_CLIENT_ID |
Confidential-client ID |
--oidc-client-secret |
ENGRAM_OIDC_CLIENT_SECRET |
Confidential-client secret |
--ui-redirect-url |
ENGRAM_UI_REDIRECT_URL |
Auth-code callback URL |
--ui-cookie-key |
ENGRAM_UI_COOKIE_KEY |
32-byte AES-256 session-cookie key |
Split-issuer (per-application IdP) topology
Section titled “Split-issuer (per-application IdP) topology”On an IdP that mints a distinct issuer per application (for example
Authentik’s default issuer_mode), the security-preferred split — agents on a
public PKCE client and the console on a confidential client, i.e. two
apps with two different iss values — needs the two lanes to trust different
issuers. Set ENGRAM_UI_ISSUER to the console app’s issuer; the MCP bearer lane
keeps ENGRAM_OIDC_ISSUER (the agents’ app):
engram serve \ --oidc-issuer https://idp.example/application/o/engram-agents/ \ --ui-issuer https://idp.example/application/o/engram-console/ \ --oidc-client-id engram-console \ --oidc-client-secret "$SECRET" \ --ui-redirect-url https://engram.example/auth/callback \ --ui-cookie-key "$COOKIE_KEY"When ENGRAM_UI_ISSUER is unset, the console lane reuses ENGRAM_OIDC_ISSUER, so
single-application deployments need no extra configuration. An enabled console
with neither issuer set is a fail-fast startup error.
Service principals (machine-to-machine auth)
Section titled “Service principals (machine-to-machine auth)”Beyond the human/no-issuer OIDC lane above, engram supports headless service
principals — CI runners, batch jobs, other backend services — authenticating
over a third, independently-configurable lane. All three mechanisms compose
into a single verifier chain in front of the MCP bearer endpoint (withAuth,
cmd/engram/serve.go):
- Human OIDC (
ENGRAM_OIDC_ISSUER, above) — tried first for any JWT-shaped bearer. - OIDC client-credentials — tried second, only for a JWT-shaped bearer the human lane didn’t accept.
- Static token — tried for any non-JWT (opaque) bearer.
A bearer is routed to lane 1+2 or lane 3 by shape alone (three dot-separated base64url segments = JWT-shaped, tried against the OIDC lanes; anything else is opaque, tried against the static-token lane) before any verifier runs — the two mechanism families never both attempt the same bearer.
Each mechanism activates independently, based on its own config being present:
| Environment variable | Default | Description |
|---|---|---|
ENGRAM_SERVICE_AUTH_OIDC_ISSUER |
(empty — lane off) | Client-credentials OIDC issuer URL. May be the same IdP as ENGRAM_OIDC_ISSUER or a distinct one. |
ENGRAM_SERVICE_AUTH_OIDC_AUDIENCE |
(empty — audience not checked) | Expected aud claim for the service lane — configured independently of ENGRAM_OIDC_AUDIENCE; tightening or loosening one never affects the other. |
ENGRAM_SERVICE_AUTH_OWNER_CLAIMS |
client_id,azp |
Ordered, comma-separated claim list the service lane resolves an owner from. Never defaults to email — a client-credentials token has no human identity to protect from an accidental email-shaped owner collision. |
ENGRAM_SERVICE_AUTH_STATIC_TOKENS |
(empty — lane off) | Comma-separated owner=token pairs, e.g. ci=tok-abc123,batch=tok-def456. Each token maps to its own distinct owner — never a single shared “service” bucket. |
A deployment with none of these set behaves byte-for-byte like today: only the human OIDC lane (or no auth at all) is active.
Owner resolution and fail-closed guarantee
Section titled “Owner resolution and fail-closed guarantee”Every mechanism resolves to the SAME store.Subject contract the human lane
uses — a service principal is isolated to its own owner bucket exactly like
any other authenticated caller (see Isolation model
below), via the same namespaced-owner encoding used for any non-email
claim.
The client-credentials and static-token lanes are fail-closed on an empty
owner: an authenticated service principal whose configured owner claim
(client_id/azp by default) is absent from the token is rejected
(401) at the verifier boundary — it never falls through to the anonymous
owner == "" bucket the way the human lane does when auth is disabled. This
is the opposite of the human lane’s fail-open-to-anonymous behavior, and it
is deliberate: an authenticated-but-unidentifiable service principal must
never silently share the anonymous bucket with unauthenticated traffic.
Before enabling the client-credentials lane, verify which claim your IdP
actually emits for the client-credentials grant — some IdPs emit client_id,
others azp, and some emit neither by default. ENGRAM_SERVICE_AUTH_OWNER_CLAIMS
accepts an ordered list (default client_id,azp) so both are tried; if your
IdP emits a different claim, add it to the list.
Static-token safety
Section titled “Static-token safety”Static tokens are compared using a constant-time comparison
(crypto/subtle.ConstantTimeCompare) over the full token value — never a
prefix or substring match — and the raw token value is never written to a
log line, error message, or trace span, on either the accept or reject path.
Static tokens are stored as plaintext in config (consistent with how
ENGRAM_OIDC_CLIENT_SECRET and ENGRAM_UI_COOKIE_KEY are already handled) —
not hashed at rest. Treat ENGRAM_SERVICE_AUTH_STATIC_TOKENS as a secret the
same way you already treat those variables.
Cross-tenant shared reads
Section titled “Cross-tenant shared reads”A shared-visibility record remains readable by any authenticated
caller — including a service principal from a different service tenant —
exactly as it already is for human callers. This is a deliberate, documented
decision for v0.11.x, not an oversight: see
docs/adr/engram-svct-service-tenant-global-shared-read.md
for the full rationale. Per-tenant shared-read scoping is deferred to a
future full tenant/group/role ABAC milestone. The tenancy-isolation guarantee
below applies to private / owner-scoped records only.
Isolation model
Section titled “Isolation model”Each authenticated caller is identified by the value of the configured owner
claim, stored as the record’s owner. The owner claim is set via
ENGRAM_OWNER_CLAIM / --owner-claim (default email) and accepts an
ordered, comma-separated list of claims tried in order — e.g. email,sub
tries email first and falls back to sub for a service/machine token that
has no email claim, instead of failing closed. Authentication fails closed
when every claim in the list is absent from the token. A winning email
claim always requires email_verified; a winning non-email claim is
written as a namespaced owner string (see
Upgrading to namespaced service owners
below) so it can never collide with an email owner or another claim’s
owner.
Read access
Section titled “Read access”| Caller type | What is readable |
|---|---|
| Authenticated (owner claim present) | Own records plus records where visibility == "shared" |
| Anonymous (no issuer, or auth disabled) | Only ownerless records (owner == "") — cannot read shared records |
The shared read grant explicitly requires an authenticated caller with a
resolved owner claim. Anonymous callers cannot read other actors’ shared records
even when visibility is set to "shared".
Write access
Section titled “Write access”Writes (update, delete, set_visibility) always require ownership:
- Authenticated callers: must be the record’s
owner - Anonymous callers: may only mutate records where
owner == ""
Sharing (visibility == "shared") grants read only — it never grants write
access to any other caller.
Fail-closed design
Section titled “Fail-closed design”- A record that exists but is not readable by the caller returns
not found(ownership never leaks across actors) - An unknown or nil subject fails closed, returning zero results rather than over-returning
- A validated token with a missing or absent owner claim is rejected (fails closed rather than collapsing to anonymous)
Upgrading an existing deployment
Section titled “Upgrading an existing deployment”Records written before per-actor isolation was introduced carry no owner key
(distinct from an empty-string owner). Once the new binary starts, these
pre-isolation records are invisible to every read and cannot be cleared by
delete_all.
The server logs a startup warning when owner-less records exist. Claim them with
migrate-remap-owner (migrate-set-owner is a deprecated alias for
--from-missing):
# Backfill pre-isolation records that have no owner setengram migrate-remap-owner --from-missing --to <owner-claim-value> --apply
# Re-stamp records that were written when owner was derived from sub# (e.g. after switching ENGRAM_OWNER_CLAIM from "sub" to "email")engram migrate-remap-owner --from <old-sub-value> --to <email-value> --applyBoth forms preview by default — drop --apply from either command above to
see the eligible count without writing anything — and accept --timeout to
cap the operation. The command is idempotent — re-running when no
matching records remain reports 0.
Note: changing
ENGRAM_OWNER_CLAIM(including upgrading from an older binary that always usedsub) invalidates existing web-console session cookies. Users see a one-time re-login prompt on their next console visit.
Disabling auth after it was enabled
Section titled “Disabling auth after it was enabled”Records written while authenticated carry a non-empty owner. If you remove
--oidc-issuer, callers fall into the anonymous bucket and can no longer read
those records — including ones marked shared (the shared read grant requires
an authenticated caller). The records are not lost and not deleted; they
become readable again once authentication is re-enabled.
migrate-remap-owner --from-missing only backfills pre-isolation records (those
missing an owner key entirely). It requires a non-empty --to, so it is safe
to re-run.
Upgrading to namespaced service owners
Section titled “Upgrading to namespaced service owners”ENGRAM_OWNER_CLAIM accepts an ordered, comma-separated claim list (default
email) so a service/machine token with no email claim can resolve to a
stable owner instead of failing closed — e.g. email,sub tries email first
and falls back to sub when email is absent or an empty string. A winning
non-email claim’s value is encoded into a namespaced owner string using
a length-prefixed scheme,
<len(claim)>:<claim>:<len(value)>:<value>, so two different (claim, value)
pairs — and a bare email owner — can never collide.
Moving from a single-value email-only deployment to a multi-claim list is a
rollout migration, not just a config change, because the owner string
is the authorization key compared directly on every read/write:
-
Web-console sessions are invalidated automatically. The session cookie payload carries a version; upgrading to a release with this change rejects every pre-upgrade cookie and forces a one-time re-login, so no bare-owner cookie can be silently forwarded into the new namespaced owner space. This is the same automatic re-login behavior already documented above for an
ENGRAM_OWNER_CLAIMchange — no manual--ui-cookie-keyrotation is required. -
Existing non-
emailowner records need remapping. If you previously ran withENGRAM_OWNER_CLAIMset to a non-emailclaim (sub,client_id, etc.), those records still carry the OLD bare claim value asowner. Remap them to the new encoded form withengram migrate-remap-owner, deriving the encoded target yourself as<len(claim)>:<claim>:<len(value)>:<value>:Terminal window # sub owner "svc-1" -> encoded target "3:sub:5:svc-1"engram migrate-remap-owner --from svc-1 --to 3:sub:5:svc-1 # preview (no --apply)engram migrate-remap-owner --from svc-1 --to 3:sub:5:svc-1 --apply# client_id owner "app42" -> encoded target "9:client_id:5:app42"engram migrate-remap-owner --from app42 --to 9:client_id:5:app42 # preview (no --apply)engram migrate-remap-owner --from app42 --to 9:client_id:5:app42 --applyemail-owned records need no migration — a winningemailclaim is still written bare, byte-for-byte unchanged.
Pre-1.0 self-hosted deployments that have never set ENGRAM_OWNER_CLAIM to a
non-email claim have zero affected records — this section applies only to
fleets that opted into a non-email owner claim before this release.