RememberStackremember.dev/docs

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:

  1. 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
  2. Publish the port on every interface, or on one interface's address:

    # .env
    REMEMBERSTACK_SELFHOST_API_PUBLISH_ADDRESS=0.0.0.0
  3. 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.

  1. Generate a secret:

    openssl rand -hex 32
  2. Put it in .env and apply it:

    # .env
    REMEMBERSTACK_SELFHOST_API_BEARER_TOKEN=<the secret>
    docker compose up -d
  3. 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.

VariableMeaning
REMEMBERSTACK_SELFHOST_API_KEY_ISSUERThe 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_IDThe issuer's id for the group of deployments this one belongs to.
REMEMBERSTACK_SELFHOST_API_KEY_PROJECT_IDThe issuer's id for this deployment's project. Default: the deployment id.
REMEMBERSTACK_SELFHOST_API_SIGNING_KEYS_URLWhere the issuer's public keys (a JWKS, {"keys": [...]}) are fetched.
REMEMBERSTACK_SELFHOST_API_REVOCATION_URLWhere the issuer's signed revocation document for this deployment is fetched.
REMEMBERSTACK_SELFHOST_API_KEY_REFRESH_SHow often both are fetched, in seconds. Default 60.
REMEMBERSTACK_SELFHOST_API_REVOCATION_MAX_AGE_SHow 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:

ClaimMeaning
issThe issuer, equal to REMEMBERSTACK_SELFHOST_API_KEY_ISSUER.
audThis deployment's id. A document for another deployment is rejected.
seqAn integer the issuer increases on every document it issues.
iat, expWhen it was issued and when it expires (iat + the maximum age).
revokedjti values refused despite a valid signature.
active_kidsThe 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 example rmb_), so secret scanners can recognise a leaked key. A bare JWT is also accepted. The header's kid must name a fetched key that is in active_kids.
  • Every key: iss, aud (one string), sub, kind, permissions (a list of strings), iat, nbf, exp, and a non-empty jti not in revoked. 30 seconds of leeway on exp and nbf.
  • Per kind:
kindaudorgprojectssub
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 idthe person
session (a short-lived key for this one deployment)this deployment's idthe tenant idexactly [<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:read gives the read scope, memory:write the write scope, and memory:ingest the ingest scope (see Scopes). memory:write wins when present; memory:read and memory:ingest together without it are refused. memory:ingest is accepted only on a session key. Permissions without the memory: prefix (such as account:read) are ignored. An unknown memory: permission is refused. A key with no memory: permission gets 403 on 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.

ScopeMay call
readGET /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
ingestPOST /ingest only. An ingest credential cannot read.
writeEverything, 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.

StatusMeaning
401No Authorization header, the credential did not verify, or (for signed keys) no fresh revocation document has been accepted
403The 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.1 and http://[::1], with any port, are accepted, so http://localhost:3000 works during development.
  • Only GET, POST and DELETE with the Authorization and Content-Type headers are allowed. DELETE (deleting a document) still needs a token with write scope. 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.