DDR-005 — Opt-in authentication for the Streamable HTTP transport¶
Version: 0.1.0 | Date: 2026-09-16 | Status: Proposed
Context¶
Engrama has shipped a Streamable HTTP transport since 0.12, and since
0.13.0 every node and relation is owned by an (org_id, user_id)
identity with fail-closed reads. resolve_scope() in
engrama/adapters/mcp/server.py derives that identity per request from
the X-Engrama-Org-Id / X-Engrama-User-Id headers, and
ENGRAMA_REQUIRE_IDENTITY makes a header-less request fail closed
instead of pooling into the standalone identity.
What is missing is the other half: nothing authenticates the caller.
The transport validates Origin and Host for DNS-rebinding protection
(engrama/adapters/mcp/http.py) and stops there. The identity headers
are therefore trusted input — correct when an authenticating gateway is
the only thing that can reach the server, and dangerous in any other
topology, because a caller who can reach /mcp can name any identity it
likes.
That leaves remote self-hosting as "the operator's problem", which is where #121 comes in: an EU platform offered to publish a one-click deploy recipe for Engrama. The honest answer was that a one-click deploy of an unauthenticated memory server is a footgun, and that putting a single shared static bearer token at the edge would make that token the entire authentication layer of somebody's long-term memory. The same request keeps arriving in other shapes — someone wanting a private remote instance for a phone client, a team wanting one agent host reachable from several machines.
The groundwork is already in the tree, deliberately: --auth-issuer /
ENGRAMA_AUTH_ISSUER is already parsed, and
/.well-known/oauth-protected-resource (RFC 9728) already returns 404
until an issuer is configured, with a comment promising that "the auth
phase only has to set an env var, not touch code". This DDR is that
phase.
Two constraints shape the design. Authentication must not become a dependency, a configuration step or a behaviour change for the majority of installs, which are single-user and local — so everything here is opt-in and inert by default. And Engrama must not grow into an identity provider: verifying a token is in scope, issuing one is not.
Decision¶
Add two opt-in authentication modes to the Streamable HTTP transport, both disabled by default, and make the identity headers untrusted whenever either one is active. The stdio transport is unaffected.
Concretely¶
-
OAuth 2.1 resource-server mode.
engrama/adapters/mcp/auth.pyvalidates an RS256 bearer JWT against a configured issuer: signature (JWKS, lazily fetched and cached bykid),iss,aud,exp, and optionally a set of required scopes. Engrama is a pure resource server — it mints nothing, registers no clients, and implements no flows. Bring your own IdP (Keycloak, Authentik, Auth0, Entra, …). -
New configuration, all unset by default.
ENGRAMA_AUTH_ISSUER— already parsed today; setting it now also turns on token validation, not just discovery metadata.ENGRAMA_AUTH_AUDIENCE— required when the issuer is set.ENGRAMA_AUTH_JWKS_URI— optional override; derived from the issuer's OIDC metadata when absent.ENGRAMA_AUTH_SCOPES— optional CSV of required scopes.-
ENGRAMA_AUTH_ORG_CLAIM— optional claim naming the caller's organisation. -
Claims → identity.
user_idis always thesubclaim, the per-user isolation boundary.org_idis the configured org claim when set and present (first entry if the claim is a list), elsesub, so a single-user deployment getsorg_id == user_idwith no configuration. A token that would resolve either value to the reserved__entity__org-shared sentinel is rejected outright: a token must never be able to name the org-shared scope. -
Header trust inverts when auth is on. With authentication enabled, inbound
X-Engrama-*identity headers are stripped before the request reaches the tools, and replaced with the pair derived from the validated claims. There is exactly one authority for identity per deployment: the token when auth is on, the fronting gateway when it is off. They are never mixed, because accepting both would let any caller forge another tenant's scope by adding a header. -
Static service-token mode.
ENGRAMA_AUTH_TOKENgates the endpoint on a single shared secret, compared in constant time, for single-tenant or ephemeral deployments where every caller is the same principal. It maps to one stable identity (ENGRAMA_AUTH_TOKEN_SUB, defaulting to the standalone identity). This mode is honest about what it is: one principal, no per-user isolation, no revocation short of rotating the secret. It is mutually exclusive with the OAuth mode — configuring both fails fast at startup rather than leaving the precedence ambiguous. -
Discovery and challenges. With an issuer configured,
/.well-known/oauth-protected-resourceserves the RFC 9728 document it already knows how to build, and a rejected request answers401withWWW-Authenticate: Bearer resource_metadata="<url>", so an MCP client can discover where to authenticate instead of guessing./healthand/.well-known/*remain unauthenticated. -
Optional dependency. Token validation lives behind a new
engrama[auth]extra (PyJWT + cryptography). The base install stays dependency-light, and a deployment that configures an issuer without the extra fails at startup with an actionable message. -
Failure mode. Misconfiguration fails closed and loud at startup — an issuer without an audience, an unreachable JWKS at first use, both modes configured at once. A server that cannot validate tokens must not start serving
/mcpunauthenticated.
Consequences¶
-
Positive. Remote self-hosting becomes a supported configuration instead of a documented hazard, which unblocks platform deploy recipes (#121) and every "I want my own private instance" request behind them. The security docs stop having to say "ships without authentication — keep it on loopback". Defence in depth improves even for deployments that already sit behind a gateway, since the inner server can verify the same tokens rather than trusting injected headers.
-
Negative. The project now ships security-critical code: a bug in token validation is a vulnerability, not an inconvenience, and it raises the bar for reviews and releases in this area permanently. The configuration surface grows by six variables, and the deployment matrix grows with it: unauthenticated, token, and OAuth are three paths that all need covering in tests.
-
Neutral. Default behaviour is unchanged: no issuer and no token means today's server, byte for byte, and stdio never consults any of this. Deployments that authenticate at a gateway and inject identity headers keep working exactly as they do now, because with auth disabled the header path is untouched; migrating them to token verification is optional and independent of this change.
Open follow-ups (not blocking)¶
- Opaque-token introspection (RFC 7662) for issuers that do not use JWTs.
- Per-tool scope mapping, so a token can be read-only across the graph rather than all-or-nothing at the endpoint.
- Rate limiting and an audit trail for authentication failures; today a brute-force attempt is invisible beyond ordinary logs.
- mTLS as an alternative to bearer tokens for machine-to-machine deployments.
- Dynamic client registration and token issuance stay out of scope permanently. Engrama is a resource server; anything that mints tokens belongs in an identity provider or a gateway in front of it.
References¶
- #121 — feat: add deploy to Dockhold — the request that prompted this.
docs/security.md— tenant isolation and the current network-exposure caveat.docs/saas/streamable-http.md— the transport this applies to.- RFC 9728 — OAuth 2.0 Protected Resource Metadata.
- RFC 6750 / RFC 9068 — Bearer token usage and JWT access tokens.
- MCP specification — Authorization.