Status: Introduced in v0.8; this page documents the current shipped contract.
ai-memory is single-tenant data with optional multi-user
attribution. Every authenticated request sees the same wiki pages —
there is no per-page RBAC, no per-user data scoping, no group
permissions. What multi-user mode adds is who-did-this: every write
attributes to a named user, audit-log rows carry that identity, and the
web UI can show "Last edited by Alice Smith" instead of the anonymous
default. Every /admin/* endpoint stays root-only once at least one user
row exists, including read-only status/search/read-page helpers.
If you run ai-memory alone, you can skip this page — your install keeps working unchanged.
You probably want multi-user mode when:
- More than one human shares a single ai-memory server (a household, a small team's homelab).
- You want the audit log to record who made each write (e.g. to
trace
Codexwrites vsClaude Codewrites vs hand-rolled CLI calls). - You're planning to use the admission webhook chain — webhooks receive the actor identity in their payload.
You probably don't need it when:
- You're the sole human user of your install. Single-user mode (no user rows)
remains compatible whether or not
inithas generated[auth].token_pepper. - You need permissions / access control. v1 of ai-memory does
not implement RBAC by design (see
design-decisions.md§13). Attribution records who did a write; it does not gate whether they could.
Every HTTP request is resolved to one of four authentication tiers:
| Rung | Trigger | What the request gets |
|---|---|---|
| 0 — Anonymous | No [auth].bearer_token set. |
Allowed, no identity. Same as pre-multi-user defaults. |
| 1 — Root | Bearer matches [auth].bearer_token. |
Allowed as root. When [auth].root_username is set, writes attribute to that name; otherwise attribution stays anonymous. |
| 2 — DB user | Bearer doesn't match root, matches a users.token_hash row (via SHA-256 of token + [auth].token_pepper). |
Allowed as that user for normal read/write APIs. All /admin/* endpoints are root-only in multi-user mode. The audit log records the username/email/name. |
| 3 — 401 | Bearer present but matches nothing. | Rejected. Closes the bypass — unknown bearers can't slip through as anonymous. |
The rungs are sticky: a request is matched at the lowest tier that applies, never escalates. Root token always wins over any users-row collision; the two namespaces are intentionally distinct.
Request identity and authorization are separate:
ActorContextcarries who made the request and is used for attribution, frontmatter, audit payloads, and active-project keys.AuthLevelcarries what auth tier the middleware resolved.AuthLevel::authorize(Capability::...)is the shared permission check for admin routes, user-management routes, normal read/write surfaces, and the admission-chain skip header.
Handlers should not compare usernames, infer root from ActorContext, or add
ad hoc root-only branches. PRs that touch auth behavior should cover root,
DB-user, and anonymous callers, including the single-user compatibility mode
where [auth].token_pepper is absent.
Prerequisite: a fresh
ai-memory init. Pre-v0.8 installs need the migration step below before any of these commands work.
Edit your config.toml (typically <data_dir>/config.toml or
/etc/ai-memory/config.toml) and uncomment the root_* lines in
the [auth] block:
[auth]
bearer_token = "<your-existing-token-or-a-fresh-one>"
token_pepper = "<auto-generated-by-ai-memory-init>"
root_username = "boss" # required for root attribution
root_email = "boss@example.com" # optional, surfaced in UIs
root_name = "Boss" # optional, surfaced in UIstoken_pepper was auto-generated by ai-memory init; do not
change it after adding users — rotating the pepper invalidates
every existing token. The pepper is what makes a stolen users
table useless to an offline attacker; an attacker with both the
DB and the config has tokens at their disposal anyway, so the
pepper's job is closed by the file-permission boundary.
init creates the pepper before any users exist. Until the first user is
added, operational admin endpoints retain single-user compatibility; creating
that first user switches them to root-only immediately, without a restart.
Expired user rows still keep admin mode root-only. If a database has users but
either the pepper or static root bearer is missing or blank, serve refuses
startup. Restore both original secrets from configuration backup (or set the
root bearer from the secret manager) rather than removing users; the root token
is required to administer the existing users.
Each ai-memory user add issues one token, printed exactly
once. Only its SHA-256 digest is kept in the DB.
$ AI_MEMORY_AUTH_TOKEN=<root-token> \
ai-memory user add --username alice --email alice@home --name "Alice Smith"
✓ created user 'alice'
name: Alice Smith
email: alice@home
id: 01935a82-6f7a-7d22-b8c0-...
Store this token now — it will NOT be shown again. Only its
SHA-256 digest is kept in the DB.
mYi3pq...<43-chars>...wKp2Zestderr carries the human chrome, stdout carries the bare token
so you can pipe it (> ~/.config/ai-memory/alice.token).
$ AI_MEMORY_AUTH_TOKEN=<root-token> ai-memory user list
USERNAME NAME EMAIL STATUS
alice Alice Smith alice@home active
bob - bob@home active
carol - - expiredThe list never surfaces tokens — only their hashes are in the DB.
ai-memory user expire <username> stamps token_expired_at = now()
on the row. The user's bearer stops authenticating immediately, but
the row stays put so historical author_id references in
audit_log and pages keep resolving to their
real names.
$ ai-memory user expire alice
Expire token for user 'alice'? Their token stops authenticating immediately. (y/N) y
✓ expired token for user 'alice'Pass --yes to skip the prompt (CI / scripts).
To re-enable later: ai-memory user revive alice.
$ ai-memory user rotate-token alice
Rotate token for user 'alice'? Any existing client using the old token will start getting 401 immediately. (y/N) y
✓ rotated token for user 'alice'
Store this token now — it will NOT be shown again.
XGqsBp...<43-chars>...zRm0VtRotation implicitly revives an expired token — you can recover an
offboarded user without first running revive.
If you're upgrading from a pre-v0.8 ai-memory:
- No action is required. Your existing
[auth].bearer_token-only setup continues to authenticate exactly as before. The auth middleware just stamps an anonymousActorContextand your audit log records the same shape it did before. - The
userstable is added by migration V14 and stays empty until you actively runai-memory user add. SQL queries against it return no rows; the rest of the schema is unchanged. - Multi-user mode requires
[auth].token_pepper. Without it, the user-management endpoints return 503 with a clearmulti-user not enabledmessage. Existing installs never trip this because they never calluser add. /admin/*endpoints are open to the configured bearer token in single-user mode, matching historical behavior. Creating the first user row immediately makes every admin endpoint root-only; DB-user tokens receive 403 and anonymous requests receive 401. Merely configuring[auth].token_pepperdoes not activate that boundary.
ai-memory init is idempotent and won't overwrite a config it
finds. To populate token_pepper without losing your current
config:
-
Back up the existing config (
cp config.toml config.toml.bak). -
Generate a pepper:
ai-memory generate-auth-token 32— this prints a hex string of the same shapeinitwould have generated. -
Add the
[auth]block to yourconfig.toml:[auth] # ... your existing settings (bearer_token, etc.) ... token_pepper = "<paste-the-generated-pepper-here>" root_username = "boss" # optional; enables root-token attribution root_email = "boss@..." # optional root_name = "Boss" # optional
-
Restart
ai-memory serve. The new fields are picked up; existing behaviour is unchanged.
You can defer steps 3-4 indefinitely — bearer_token alone keeps
working as it always has.
- 32 bytes of OS CSPRNG, URL-safe-base64-encoded → 43-character string.
- DB column
users.token_hashstoresSHA-256(token || ":" || token_pepper), never the plaintext. - The per-server
token_peppermakes a DB-only theft (e.g. a copied SQLite file) useless to an offline attacker: the search space for the unpeppered hash is(token, pepper)jointly. - Constant-time comparison (
subtle::ConstantTimeEq) on the hash side-steps timing attacks against the lookup path.
We deliberately don't use argon2id here even though it would be
the textbook choice. Tokens are 256-bit CSPRNG, so brute force is
infeasible regardless of hash strength; argon2id's per-hash salt
would force O(N) scans on every auth request, where SHA-256 +
UNIQUE index gives us the O(1) lookup the hot path needs.
See crates/ai-memory-store/src/users.rs for the full rationale.
| Surface | Status |
|---|---|
Auth middleware injects Extension<ActorContext> on every request |
✓ P1.3 |
All /admin/* routes gate on Extension<AuthLevel>::Root in multi-user mode |
✓ P1.4 |
ai-memory user add/list/expire/revive/rotate-token CLI |
✓ P1.5 |
pages.author_id populated, frontmatter last_modified_by block |
✓ P1.6 |
/api/v1 page responses include author: { username, name?, email? } |
✓ P1.7 |
| ETag invalidation on author change (so caches refresh attribution) | ✓ P1.7 |
install-hooks --as-user <name> metadata + flag validation |
✓ P1.8 |
| Web UI shows author on the page view | ✓ shipped |
Attributed mutation audit rows carry audit_log.author_id |
✓ shipped |
Commit ids for each milestone are recorded in CHANGELOG.md.
After ai-memory user add prints a user's token, point that user's
agent install at it via install-hooks:
$ ai-memory user add --username alice --email alice@home --name "Alice Smith"
✓ created user 'alice'
name: Alice Smith
email: alice@home
...
XGq...<43-chars>...zRm # the token, stdout only
$ ai-memory install-hooks --apply --agent claude-code \
--as-user alice --auth-token XGq...<43-chars>...zRm
[ai-memory] hooks installing for user: alice
✓ staged 5 hook script(s) → ...--as-user is metadata only: it labels the install for the
operator's records and prints a confirmation line so you can verify
which identity the next session's writes will attribute to. The
actual token wired into the hook env block is whatever you pass via
--auth-token. Mismatching the two (e.g. --as-user alice --auth-token <bob's token>) is permitted at the CLI layer; the
server will resolve to bob at runtime. The flag is there to keep the
operator honest, not to enforce.
Without --as-user, hooks install the same way they always have —
the bearer authenticates, attribution flows from the token's owner
(root user or DB user) at write time.
- No per-page RBAC. Every authenticated user sees every page in
the workspace. All
/admin/*endpoints are still root-only in multi-user mode. If you need data isolation, run separate ai-memory servers (per-user data dirs) and front them with a reverse proxy. - One token per user. Rotation issues a new token and invalidates the old in the same transaction. There's no notion of multiple device-bound tokens per user.
- Root token is single.
[auth].bearer_tokenis the admin token for every/admin/*endpoint. DB users created withuser addare normal users, not additional admins. - OIDC is request authentication, not page authorization. Native hooks and
thin-client CLI commands can send a per-developer OIDC bearer for an external
OIDC-aware gateway/bridge. Native ai-memory server auth still uses static root
bearer / DB-user tokens, and
/admin/*stays root-only unless a gateway translates accepted OIDC auth into upstream auth that ai-memory accepts. ai-memory still has one shared wiki per server and no per-page RBAC. The Keycloak/OIDCsidclaim is also not an ai-memory agent session id; session auto-scope needs the lifecycle-hook session id or explicitworkspace+project/scopes.