Authentication and scopes
A fresh self-hosted deployment accepts every request without a token. That is convenient on your own laptop and unsafe anywhere else. This page shows how to require a token, what each kind of token may do, and how to let a browser app call the API.
Open by default, on this machine only
With no authentication variable set, the API has no perimeter: any caller that reaches port 8000 can read the whole memory, send documents, and run every operation. Nothing is logged about who they were.
So compose.yaml publishes the API on the loopback interface only
(127.0.0.1:${REMEMBERSTACK_SELFHOST_API_PORT}:8000): other machines
cannot connect. (The object store publishes no host port at all.)
Opening the API to other machines
Set a token first, then choose the address to publish on:
-
Set a shared secret as described in A shared secret, and make the API refuse to start without one:
# .env REMEMBERSTACK_SELFHOST_API_BEARER_TOKEN=<the secret> REMEMBERSTACK_SELFHOST_REQUIRE_API_AUTH=true -
Publish the port on every interface, or on one interface's address:
# .env REMEMBERSTACK_SELFHOST_API_PUBLISH_ADDRESS=0.0.0.0 -
Apply it:
docker compose up -d
The API speaks plain HTTP. Across an untrusted network, put a TLS-terminating reverse proxy in front of it, keep the publish address on loopback, and let the proxy reach it there.
A shared secret
The simplest perimeter is one secret that every client presents.
-
Generate a secret:
openssl rand -hex 32 -
Put it in
.envand apply it:# .env REMEMBERSTACK_SELFHOST_API_BEARER_TOKEN=<the secret>docker compose up -d -
Give it to the client:
export REMEMBER_API_URL=http://localhost:8000 export REMEMBER_API_KEY=<the secret> # the Python client and the CLI
The client sends it as Authorization: Bearer <secret>. The API compares a
SHA-256 digest of what it receives with the digest of the configured
secret, bound to this deployment's id. The shared secret has full write
scope. A request without it gets 401.
Keep only the digest on the server
REMEMBERSTACK_SELFHOST_API_BEARER_BIND configures the same check without
the secret itself in the container's environment. Its value is the
deployment id and the hex SHA-256 of the secret, joined by a colon:
SECRET=<the secret>
DEPLOYMENT_ID=<your REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID>
DIGEST=$(printf '%s' "$SECRET" | shasum -a 256 | cut -d' ' -f1) # sha256sum on Linux
echo "REMEMBERSTACK_SELFHOST_API_BEARER_BIND=$DEPLOYMENT_ID:$DIGEST"Use printf '%s', not echo: a trailing newline changes the digest.
The id in the bind must be this deployment's id; a bind made for another
deployment rejects every request with 403 ("credential is for another
deployment"). If you set both the token and the bind, they must describe
the same secret, or the API refuses to start.
Signed keys
For more than one credential, or for credentials that expire and carry narrower permissions, the API verifies signed keys (JWTs) minted by a key issuer: a service of your own, or any other, that follows the contract below. The deployment holds only the issuer's public keys, so it can check keys but never mint them. Signed keys and the shared secret can be configured together.
| Variable | Meaning |
|---|---|
REMEMBERSTACK_SELFHOST_API_KEY_ISSUER | The issuer's URL, compared exactly with the iss claim. Setting it turns signed keys on; the tenant id and both URLs below are then required. |
REMEMBERSTACK_SELFHOST_API_KEY_TENANT_ID | The issuer's id for the group of deployments this one belongs to. |
REMEMBERSTACK_SELFHOST_API_KEY_PROJECT_ID | The issuer's id for this deployment's project. Default: the deployment id. |
REMEMBERSTACK_SELFHOST_API_SIGNING_KEYS_URL | Where the issuer's public keys (a JWKS, {"keys": [...]}) are fetched. |
REMEMBERSTACK_SELFHOST_API_REVOCATION_URL | Where the issuer's signed revocation document for this deployment is fetched. |
REMEMBERSTACK_SELFHOST_API_KEY_REFRESH_S | How often both are fetched, in seconds. Default 60. |
REMEMBERSTACK_SELFHOST_API_REVOCATION_MAX_AGE_S | How old the accepted revocation document may get before signed keys stop working, in seconds. Default 3600. |
compose.yaml passes all seven to the API.
The public keys
The API fetches the JWKS every refresh interval. EdDSA over Ed25519 only:
every key must have "kty": "OKP", "crv": "Ed25519", a string kid, no
private part (d), and, if present, "use": "sig" and a key_ops list
containing verify. A key set with one bad key is refused as a whole, and
a failed fetch keeps the last good set. {"keys": []} is valid and refuses
every signed key.
The revocation document
A signed key cannot be un-signed, so the issuer publishes a revocation
document: a JWT signed with one of its keys, header typ set to
revocation+jwt, with these claims:
| Claim | Meaning |
|---|---|
iss | The issuer, equal to REMEMBERSTACK_SELFHOST_API_KEY_ISSUER. |
aud | This deployment's id. A document for another deployment is rejected. |
seq | An integer the issuer increases on every document it issues. |
iat, exp | When it was issued and when it expires (iat + the maximum age). |
revoked | jti values refused despite a valid signature. |
active_kids | The kids whose keys are still valid. |
The API accepts the first document whose signature, iss and aud check
out. After that, a new document must have a higher seq and be signed by a
kid listed in active_kids of the document it replaces; anything else is
rejected and logged, and the last accepted document stays. The accepted
document is stored in the database, so a restart does not lose it.
No fresh document, no signed keys. Until a document is accepted, and
whenever the accepted one is older than the maximum age (one hour by
default) or past its exp, every signed key gets 401. The
shared secret keeps working. An issuer re-issues the document every refresh
interval; a key revoked at time r stops working by r plus the maximum
age plus 30 seconds, even if the issuer is unreachable.
To retire a signing key, remove its kid from active_kids. Keys signed
with it stop working from the next accepted document, even while the
public key is still in the JWKS.
What a key must carry
- Form:
<prefix>_<JWT>, where the prefix is letters only (for examplermb_), so secret scanners can recognise a leaked key. A bare JWT is also accepted. The header'skidmust name a fetched key that is inactive_kids. - Every key:
iss,aud(one string),sub,kind,permissions(a list of strings),iat,nbf,exp, and a non-emptyjtinot inrevoked. 30 seconds of leeway onexpandnbf. - Per
kind:
kind | aud | org | projects | sub |
|---|---|---|---|---|
key (a long-lived key a person created) | org:<tenant id> | the tenant id | "org:*" (every project), or a list of 1 to 20 project ids that includes this deployment's project id | the person |
session (a short-lived key for this one deployment) | this deployment's id | the tenant id | exactly [<project id>] | the person |
service (a machine credential) | this deployment's id | — | — | exactly dpcred:<jti> |
A session may also carry src, naming the service that derived it; it
is recorded, never used to decide access. Any other aud, such as an OAuth
token meant for a hosted MCP server, is refused.
- Permissions:
memory:readgives thereadscope,memory:writethewritescope, andmemory:ingesttheingestscope (see Scopes).memory:writewins when present;memory:readandmemory:ingesttogether without it are refused.memory:ingestis accepted only on asessionkey. Permissions without thememory:prefix (such asaccount:read) are ignored. An unknownmemory:permission is refused. A key with nomemory:permission gets403on every route.
This example creates a key pair, prints the JWKS, and signs a revocation
document and a one-hour read key. It needs pip install "pyjwt[crypto]".
Serve the JWKS and the document at the two URLs, and sign a new document
(with a higher seq) at least every refresh interval:
import json
import time
import uuid
import jwt
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from jwt.algorithms import OKPAlgorithm
ISSUER = "https://issuer.example.com" # REMEMBERSTACK_SELFHOST_API_KEY_ISSUER
TENANT = "my-team" # REMEMBERSTACK_SELFHOST_API_KEY_TENANT_ID
DEPLOYMENT_ID = "<your REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID>"
private_key = Ed25519PrivateKey.generate()
public_jwk = json.loads(OKPAlgorithm.to_jwk(private_key.public_key()))
public_jwk.update({"kid": "k1", "use": "sig"})
print(json.dumps({"keys": [public_jwk]})) # serve at API_SIGNING_KEYS_URL
now = int(time.time())
revocation = jwt.encode(
{
"iss": ISSUER,
"aud": DEPLOYMENT_ID,
"seq": now, # any integer that grows with every document
"iat": now,
"exp": now + 3600,
"revoked": [],
"active_kids": ["k1"],
},
private_key,
algorithm="EdDSA",
headers={"kid": "k1", "typ": "revocation+jwt"},
)
print(revocation) # serve at API_REVOCATION_URL
key = jwt.encode(
{
"iss": ISSUER,
"aud": f"org:{TENANT}",
"org": TENANT,
"projects": [DEPLOYMENT_ID],
"sub": "alice",
"kind": "key",
"permissions": ["memory:read"],
"iat": now,
"nbf": now,
"exp": now + 3600,
"jti": str(uuid.uuid4()),
},
private_key,
algorithm="EdDSA",
headers={"kid": "k1"},
)
print("mykey_" + key)In real use, store the private key somewhere safe; this script discards it when it exits.
Scopes
Every request that passes the perimeter is checked against the scope of its
credential. The route decides the scope it needs, not the HTTP method:
several reads use POST because their arguments do not fit in a URL.
| Scope | May call |
|---|---|
read | GET /resolve, /lookup/relations, /lookup/observations, /transcript/relation/{id}, /hydrate/relation/{id}, /search/claims, /search/chunks, /chunks/{id}/adjacent, /query/space, /query/space/search, /query/saved, /query/saved/{namespace}/{name}, /operations, /connectors, /connectors/{id}, /documents; POST /search/claims, /search/chunks, /chunks/adjacent, /graph/neighborhood, /graph/path, /graph/citation-path, /query/sql, /query/sql/explain, /query/saved/{namespace}/{name}/run, /readiness |
ingest | POST /ingest only. An ingest credential cannot read. |
write | Everything, including every route not listed above |
Routes that are not in the read or ingest list need write. Today that
includes POST /connectors and POST /connectors/{id}/pause.
POST /operations/{name} (the assured operations)
decides per operation: an operation that declares itself read-only needs
read, any other needs write. All four shipped operations only read and
declare it (mutates: false), so a read token can run them.
GET /healthz never needs a credential.
| Status | Meaning |
|---|---|
401 | No Authorization header, the credential did not verify, or (for signed keys) no fresh revocation document has been accepted |
403 | The credential is for another deployment, or its scope does not cover the route |
REMEMBERSTACK_SELFHOST_REQUIRE_API_AUTH
The perimeter is enforced as soon as a token, a bind or a key issuer is
configured. REMEMBERSTACK_SELFHOST_REQUIRE_API_AUTH=true makes that a
start condition: the API refuses to start unless at least one of
REMEMBERSTACK_SELFHOST_API_BEARER_TOKEN,
REMEMBERSTACK_SELFHOST_API_BEARER_BIND or
REMEMBERSTACK_SELFHOST_API_KEY_ISSUER is set. Set it on any deployment
other machines can reach, so a lost .env line cannot leave it open.
Browser origins (CORS)
A web app served from another origin cannot call the API unless the API
names that origin. REMEMBERSTACK_SELFHOST_BROWSER_ORIGINS is a
comma-separated list of exact origins:
REMEMBERSTACK_SELFHOST_BROWSER_ORIGINS=https://app.example.com,https://admin.example.com- Each entry must be an origin exactly as a browser sends it: lowercase scheme and host, an optional port, nothing else. A malformed entry, an empty entry or a trailing comma stops the API at start.
- The scheme must be
https, except for an app on the same machine:http://localhost,http://127.0.0.1andhttp://[::1], with any port, are accepted, sohttp://localhost:3000works during development. - Only
GET,POSTandDELETEwith theAuthorizationandContent-Typeheaders are allowed.DELETE(deleting a document) still needs a token withwritescope. Cookies are not allowed; the browser must send a token. - Browsers may cache the permission for up to 600 seconds after you remove an origin. Revoking the token is what stops access immediately.
Empty, the default, advertises nothing: no browser origin can call the API.
Trusted uploader headers
POST /ingest accepts two headers that name who uploaded a document:
X-Ingest-Principal-Kind (user, api_credential or service) and
X-Ingest-Principal-Ref (1 to 255 printable ASCII characters). By default
the API ignores them, because any client can send any value.
REMEMBERSTACK_SELFHOST_TRUSTED_PRINCIPAL_SOURCE=true makes the API record
them. Enable it only when every request reaches the API through a proxy or
gateway that authenticates the actor, sets these headers itself and strips
any a client sent. Even then, when authentication is configured, the
headers count only on requests with a write credential; with a narrower
credential they are ignored and the upload still succeeds. A malformed
pair on a trusted request is rejected with 422.