ADR-014: Authentication is an optional bearer token, checked at one layer, and never an MCP capability¶
Status¶
Accepted, amended by ADR-017
(the restart this record names is now memcastle daemon restart)
and by ADR-035 (with the dashboard enabled, its static files are a second public path)
Context¶
The daemon answered anyone who could reach its listener. ADR-011 kept that safe by defaulting to loopback, and deferred authentication. 0.1 needs a way to run the daemon beyond loopback, or behind a supervisor, without leaving the palace open.
The constraints come from how the daemon is used:
- The standalone local workflow must stay simple, so authentication has to be optional and off by default.
- The CLI is a thin client of the daemon's API (invariant 1 in
AGENTS.md), so it cannot have a private path for managing credentials, and there is no local socket or privileged channel. - Secrets arrive from secret managers as environment variables, so a secret must be usable without being written to a file.
- Agents reach the daemon over MCP, and an agent must not be able to mint, rotate or revoke the credential that guards it.
- OAuth/OIDC and per-user authorization are wanted later, for server deployments, and must not require rework of the daemon's request handling.
Decision¶
- A single shared bearer token, sent as
Authorization: Bearer <token>. It is enabled byauth.enabled(MEMCASTLE_AUTH_ENABLED), which defaults to false. The setting is read once, at start, from configuration only. A stored verifier alone never turns authentication on, so generating a token cannot change what the next restart does. - Two credential sources, either of which is accepted.
A configured shared secret (
auth.token, orMEMCASTLE_AUTH_TOKEN, which is the recommended way), and the token made bymemcastle auth generate. The CLI presents the configured secret, from the same two settings, and never takes it as an argument. - Generated tokens are 256 bits from the operating system, stored as a SHA-256 digest.
The format is
mc_and 64 hex digits. A token that is high-entropy by construction has nothing to brute-force, so a plain cryptographic hash is a sufficient verifier and a password KDF would only add cost. The row,auth_state:token, also records the algorithm and a version, so a later scheme is recognisable as such. A verifier from a scheme this build does not know matches nothing. - Verification is constant-time and fail-closed.
Digests are compared with
subtle. A missing, malformed or wrong token is a 401 withWWW-Authenticate: Bearerand the ordinary error body, codememcastle::auth::unauthorized. A store error while reading the verifier also refuses the request. 401 is used, not 403, so an unidentified caller is distinguishable from an identified one refused by its memory mode, and from a daemon that is not there. - One layer guards everything.
A middleware on the merged router, outside both the REST routes and
/mcp, runs before any handler or rmcp. The only exemption isGET /api/health, whichrestart, supervisors and the client's own probe rely on and which answers nothing but{"status":"ok"}. An unknown path is refused with 401 rather than 404, so the route table cannot be probed without a token. - Token administration is REST and CLI only.
POST /api/auth/tokengenerates, replacing any previous token, andDELETE /api/auth/tokenrevokes. While authentication is disabled they are open, which is the whole bootstrap: start without authentication, generate, store the token, enable authentication, restart. Once it is enabled they need a valid token like every other route. There is no MCP tool for either, and a test fails if any tool's name mentions credentials. - The verifier is read on every request. That is one read of a single row, and it makes rotation and revocation take effect immediately, without a restart.
- The daemon refuses to start when it could never authenticate anyone.
auth.enabledwith neither a configured secret nor a stored verifier ismemcastle::auth::not_configured, raised after migrations and before the scheduler starts. - Secrets have a type that cannot be logged.
config::Secrethas a redactedDebugand is skipped when the configuration is serialised. The daemon hashes a configured secret at startup and keeps only the digest. The plaintext is never logged, never part ofstatus, and never written to the registry file or any generated file.statusreports only whether authentication is enabled. - The boundary is provider-agnostic.
The HTTP layer asks
AppServices::authenticatewhether the credential a request carried is acceptable. Another provider replaces that method's body, and adds credentials and roles behind it, without touching a route.
Alternatives rejected¶
- A local Unix socket for bootstrap. It would let the CLI generate a token without the API, but it is a second channel to secure and document, it does not exist on every platform MemCastle builds for, and it breaks the rule that the CLI only calls the daemon's API.
- Enabling authentication implicitly when a secret or verifier exists.
Fewer settings, but running
auth generatewould silently change how the daemon starts next time, and the disabled-then-enable bootstrap would have no moment at which to store the token safely. - Argon2 or another password KDF for the verifier. Right for passwords a human chose, wasted on 256 random bits. It would also add a per-request cost to the layer that guards every call.
- Authenticating
/api/healthtoo.restartdecides whether to ask a daemon to shut down by probing it, and a refused probe reads as "not running". Supervisors and container health checks would need the secret. - Leaving
/api/statusopen. Friendlier to monitoring, but it tells any network client the palace path, the datastore location and the bind address. - Only
auth generate, with revocation as "turn authentication off". Rotation fits, but revoking a leaked token would mean disabling the protection it leaked from. - Caching the verifier in memory. Saves a read per request, and makes a revoked token keep working, and a shared remote palace's other daemons disagree about it, until a restart.
- Putting the token in the registry file or the configuration by
auth generate. Would make the CLI "just work", at the cost of a plaintext credential on disk that the issue forbids.
Consequences¶
- Plain HTTP carries the token in cleartext. TLS on the daemon's listener is still a non-goal, so a daemon exposed beyond a trusted network needs a TLS-terminating proxy in front of it.
- MCP clients on another host must send the header, and rmcp's default
Hostallow-list accepts only loopback names, which is independent of authentication. Authentication says how to configure a client and what the allow-list means. - Enabling authentication with only a generated token, then revoking it, leaves a daemon nothing can authenticate to, and it cannot be asked to stop by the CLI. Recovery is to restart it with authentication off, or with a configured secret; the guide says so.
- A shared remote palace has one stored token for all its daemons, and revoking it on one revokes it on all. A configured secret is per daemon.
- There is one credential and no notion of who presented it. Users, roles, scopes and OAuth/OIDC are future server-mode work, and the layer above is where they would attach.
- The
statusreport gained anauth_enabledfield, additive and defaulted, so older clients and daemons interoperate.