The canonical guide to authentication for context-intelligence-server — for a
human operator or an agent setting one up. Covers the two key modes, the local
first-run bootstrap, adding/revoking/rotating per-peer keys by hand, and the
guardrails that keep you from locking yourself (or your peers) out with a 401.
There is no
initsubcommand. Setup is either the local first-run bootstrap (scripts/prime-local-config.py, below) or the manual steps in this guide.
To add/remove users at runtime without redeploying, see docs/identity-management.md.
Using Microsoft Entra JWT auth (
auth_mode=entra) instead of pre-shared keys? This guide covers the static-key modes; for the Entra setup see entra-auth-setup.md.
The server reads two config keys. They coexist; both feed one keystore.
| Mode | Config key | Shape | Identity |
|---|---|---|---|
| Legacy single key | api_key |
a single string secret | folds to contributor id owner |
| Per-contributor keystore | api_keys |
map of sha256_hex(token) -> { id: <contributor> } |
each entry names its own id |
How a request is verified (both modes):
- The peer sends
Authorization: Bearer <raw_token>. - The server computes
sha256_hex(raw_token). - It looks that digest up in the keystore. Hit → request proceeds, stamped with
the matched contributor
id. Miss → 401.
Key facts that follow from this:
- The server stores only digests, never raw tokens. For
api_keysyou write the digest directly. For the legacyapi_keyyou write the raw secret and the server hashes it at startup. - Auth is disabled (every request passes through unauthenticated) only when
no keys are configured —
api_keysomitted/null/{}and noapi_key— AND the explicitallow_unauthenticated: trueopt-out is set. That combination is dev-only and the server logs a loud "WIDE OPEN" warning. api_keys: {}(an explicit empty map) now BOOTS fail-CLOSED, not an error. An empty keystore is a supported bootstrap state: the server starts, logs a loud empty-keystore WARNING, and every request 401s until keys are onboarded at runtime viaPUT /admin/keys/{sha256hash}(see §3.1). Onboarding at runtime requires an admin credential (admin_api_key/admin_api_key_sha256); without one, the empty keystore can only be populated by editingapi_keysin config and restarting.- Legacy
api_keystill works unchanged — existing single-key deployments are unaffected and need no migration.
Two ways to edit the keystore. This guide covers the config-file path (edit
api_keys, restart) — the right path for the day-zero bootstrap and for deployments that don't run the admin API. To add/revoke keys at runtime with no restart, use the/admin/keysAPI (gated by a separateadmin_api_key); see identity-management.md. Both edit the same map — the in-process keystore that is the source of truth at runtime.
Admin key: store the digest, not the raw token. The admin credential that gates
/admin/*follows the same at-rest rule asapi_keys— store its SHA-256 digest, never the raw token. Setadmin_api_key_sha256:to the 64-hex digest (derived with the one-liner in §2). A config-file leak then yields only a one-way digest, not a usable admin credential. The legacy rawadmin_api_key:field still works (it is hashed at load) but is deprecated — the server logs a warning at startup and, if both are set,admin_api_key_sha256wins and the raw field is ignored. Env var:AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_ADMIN_API_KEY_SHA256.# Recommended — digest at rest: admin_api_key_sha256: "<64-hex sha256 of the admin token>" # Deprecated — raw token at rest (still works, warns): # admin_api_key: "<raw admin token>"
These derive a token and its digest exactly the way the server does
(hashlib.sha256(token.encode()).hexdigest()).
# 1. Generate a fresh raw token (this is the secret the peer will hold):
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# 2. Derive the sha256 digest of that token (this is what goes in api_keys):
python3 -c "import hashlib,sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest())" "<token>"Shell-history leak — read before you run step 2. Passing the raw token as a command argument writes it into your shell history (
~/.bash_history,~/.zsh_history) and it may be visible in the process list while it runs. After deriving the digest, scrub it:history -d $(history 1)(or clear the relevant lines), or avoid the exposure entirely by deriving the digest in one step without ever putting the token on a command line:# Generate token + digest together; the token is printed once, never an argv: python3 - <<'PY' import secrets, hashlib t = secrets.token_urlsafe(32) print("token: ", t) print("digest:", hashlib.sha256(t.encode()).hexdigest()) PYTreat any token that has touched shell history as compromised — rotate it (§6) if it protects anything that matters.
For a local (non-Docker) run, scripts/prime-local-config.py does the credential
bootstrap in one shot. From the repo root:
python scripts/prime-local-config.py --neo4j-password '<neo4j-password>'It:
-
generates a random raw API token,
-
writes
server-config.yamlcontaining the Neo4j connection (bolt://localhost:7687) and anapi_keysblock holding only the digest:api_keys: "<64-hex sha256 of the token>": id: owner
plus a local
./.context-intelligence-data/tree (blobs/,queues/,logs/,identity/), and -
prints the raw token once, behind this banner:
======================================================== SAVE THIS TOKEN — it will NOT be shown again. The file stores only its SHA-256 digest, not the token. API token: <raw-token> ========================================================
Capture that token immediately. It is the bearer token your hook/clients use.
It is not recoverable from server-config.yaml — that file holds only the
digest. If you lose it, you cannot grep it back; rotate (§6) to issue a new one.
The script refuses to overwrite an existing server-config.yaml unless you pass
--force. See local-development.md §2 for the full flag
list (--data-dir, --config-path, --server-host/--server-port). Prefer to
do it by hand? Use the two one-liners in §2 and add the api_keys entry yourself.
You do not have to seed any api_keys in config. With api_keys omitted/null/{}
(and no legacy api_key), the server boots fail-CLOSED: it starts, logs a loud
empty-keystore WARNING, and every data request 401s until keys are onboarded.
To onboard keys at runtime (no restart) you need an admin credential
configured — admin_api_key_sha256 (recommended) or the legacy admin_api_key.
The admin credential gates /admin/* by itself (it is not in the data keystore),
so it works even when the keystore is empty. The sequence:
-
Boot with an empty keystore and an admin credential set. Startup logs:
static keystore is EMPTY at startup (0 bound keys) — server is UP but fail-CLOSED; every request will 401 until keys are onboarded. Add the first key with the admin token via PUT /admin/keys/{sha256hash} (store=/data/identity/api-keys.json). Expected on a fresh /data volume. -
Derive a peer token + its sha256 digest (§2), then bind the digest with the admin token:
curl -sS -X PUT "$SERVER_URL/admin/keys/<64-hex-sha256>" \ -H "Authorization: Bearer <admin-token>" \ -H "Content-Type: application/json" \ -d '{"id":"alice"}'
-
The peer's data request with their raw token now authenticates.
If NO admin credential is configured, an empty keystore cannot be bootstrapped at runtime (the
/adminAPI is itself unreachable — every token 401s at the middleware beforerequire_adminruns). Startup says so explicitly. In that case the only path is config-and-restart: addapi_keysin config and restart. Setadmin_api_key_sha256to enable the runtime path instead.
Each peer gets their own unique token so you can revoke them individually. Never share one token across people.
-
Generate a token and digest for the peer (§2). Pick a stable, lowercase contributor id (e.g.
alice,peer-laptop). -
Add the digest to the keystore. In your server config (
server-config.yaml), underapi_keys, add one entry — the digest is the key, quoted; the value carriesid:api_keys: "f1d2...<existing owner digest>...": id: owner "a3c9...<the new peer's digest>...": id: alice
-
Restart the server so it reloads the keystore.
-
Send the peer the RAW token (the output of step 1, not the digest) over a secure channel — a password manager share, an encrypted message, etc. They put it in their client as the bearer token (see peer-onboarding.md).
RAW-TOKEN vs DIGEST — the #1 way to lock yourself out. The config file holds the DIGEST (64 hex chars). The peer holds the RAW token (the
secrets.token_urlsafestring). They are not interchangeable:
- Put the raw token in
api_keys→ its digest won't match anything → every request from that peer is 401.- Send the digest to the peer → the server hashes the digest and gets a different value → 401. If you see an unexpected 401, check this first: file = digest, peer = raw token.
- Delete that contributor's entry (the digest line and its nested
id) fromapi_keys. - Restart the server.
The peer's token now hashes to a digest that is no longer in the keystore, so their requests return 401. Other peers are unaffected — this is the whole point of per-peer keys. Their already-uploaded data remains in the graph.
Rotation = issue a new token for a contributor and retire the old one.
- Generate a fresh token + digest (§2).
- Replace that contributor's digest in
api_keyswith the new one (keep the sameid). - Restart the server.
- Send the new raw token to the holder; they swap it into their client.
The old token stops working the moment the old digest leaves the keystore. To
rotate without downtime for the holder, you can temporarily keep both digests
(same id) in the keystore, let them switch, then remove the old one.
You do not have to — legacy api_key keeps working. Migrate when you want
per-peer identity/revocation. The legacy key folds to id owner; preserve that
token by carrying its digest forward.
-
Derive the digest of your existing
api_keyvalue:python3 -c "import hashlib,sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest())" "<current-api_key>"
-
Add an
api_keysblock with that digest under idowner, then remove theapi_keyline:# before: # api_key: "s3cr3t-existing-token" # after: api_keys: "<digest of s3cr3t-existing-token>": id: owner
-
Restart. The same raw token your clients already hold continues to work, because its digest is unchanged — you have only changed how the server stores it. Now you can add more peers (§4).
You can also run both keys at once during a transition: keep
api_keyand anapi_keysblock — the server merges them into one keystore. (If the same digest appears in both, theapi_keysentry'sidwins.)
Fail-closed: a bad keystore stops the server rather than silently disabling auth.
| Rule | Behavior |
|---|---|
api_keys: {} (empty map) |
Hard error. Omit or use null to disable auth. |
| Digest key not exactly 64 hex chars | Hard error. (Uppercase is accepted and normalized to lowercase.) |
A value that is not a map / missing id |
Hard error. |
id empty, whitespace-only, or non-string |
Hard error. |
api_keys omitted or null, no api_key |
Auth disabled (dev only). |
Quote the digest key in YAML. A bare 64-hex string usually parses fine, but quoting is unambiguous and avoids surprises with digests that start with a digit or look numeric.
The matched contributor id is recorded on graph data as created_by, so you can
see who contributed what. Two different guarantees:
-
Nodes — per-contributor, write-once. Each session/tool-call node is stamped with the
created_byof the contributor who first created it. This is sound because eachsession_idis owned by exactly one contributor. The stamp is write-once: once set it is never overwritten (anti-spoof). Nodes created before this feature shipped keepcreated_by = null. -
Edges (relationships) — first-asserter, weaker. Relationships also get a write-once
created_by, but edges are inherently cross-session, so it means "whoever first asserted this edge" — race-determined for cross-session edges, not ownership. It is intentionally not indexed in v1: querying edges by contributor is unsupported for now. Bare endpoint placeholder nodes that an edge creates are leftnull(not stamped) to avoid mis-attributing them across sessions.
Don't build access control on created_by. It is provenance/attribution, not an
authorization boundary.
A single, separate credential that gates the server's /admin/* endpoints.
It is not part of the data-auth keystore (api_key / api_keys) and is
never used to send events — it authorizes administration of the keystore, not
contribution to the graph. It applies to static mode only; in auth_mode=entra
the admin capability is granted by an Entra app-role claim (entra_admin_role)
instead, and the admin key is ignored.
It unlocks runtime management of the per-contributor keystore with no restart —
the /admin/keys API described in identity-management.md:
- Add a peer key while the server is running (
PUT /admin/keys). - Revoke a peer key (
DELETE /admin/keys). - Rotate keys without editing YAML and restarting.
Without an admin key configured, /admin/* is disabled (callers get 403/503)
and the only way to change keys is the config-file path (edit api_keys, restart).
So you need the admin key only if you want live keystore management; a static,
config-file-only deployment can leave it unset.
How verification works (same one-way shape as data keys): a caller sends
Authorization: Bearer <raw_admin_token>; the server computes sha256(token) and
compares it to the configured admin digest. Match → the request is treated as
admin (is_admin=True) and bypasses the data keystore; miss → falls through
to normal auth. The admin check runs before the keystore resolver.
-
Generate a token and its digest (token never touches shell history):
python3 - <<'PY' import secrets, hashlib t = secrets.token_urlsafe(32) print("admin token: ", t) # keep secret; use as Bearer print("admin digest:", hashlib.sha256(t.encode()).hexdigest()) # goes in config PY
-
Store the digest at rest in
server-config.yaml(or via env varAMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_ADMIN_API_KEY_SHA256):admin_api_key_sha256: "<64-hex admin digest>"
-
Restart the server. Confirm it came up admin-enabled — the startup log reads
create_asgi_app: auth_mode=static admin_api=enabled, and unauthenticated/statusreportsauth.admin_api_key_configured: true. -
Use the raw token to call the admin API:
curl -H "Authorization: Bearer <raw_admin_token>" https://<host>/admin/keys
Capture the raw token when you mint it. The server keeps only the digest and cannot give the token back; lose it and you mint a new one and update the config.
admin_api_key_sha256 stores the one-way SHA-256 digest, so a leak of the
config file yields no usable admin credential — the same guarantee api_keys
provides. The legacy raw field admin_api_key: "<token>" still works (it is
hashed at load) but is deprecated: it puts the secret in plaintext at rest,
and the server logs a startup warning when it is used. If both are set,
admin_api_key_sha256 wins and the raw field is ignored.
To migrate off the raw field: compute the digest of your existing admin token
(step 1's one-liner), move it to admin_api_key_sha256, delete the raw
admin_api_key, and restart.
- Treat the admin token like root for this server: it can add/revoke any contributor key. Keep it separate from ordinary per-peer ingestion tokens.
- Don't reuse the admin token as a data-ingestion key. If you do, the admin
fast-path authenticates it as
adminbefore the keystore resolver, so your events are stampedcreated_by=admininstead of your real contributorid. - Rotate it independently of data keys; because client and server hold the secret in different forms (raw vs digest), rotate both sides together.
| Rule | Behavior |
|---|---|
admin_api_key_sha256 not exactly 64 hex chars |
Hard startup error. (Uppercase is accepted and normalized to lowercase.) |
admin_api_key_sha256: "" (empty string) |
Normalized to unset (admin disabled), mirrors admin_api_key. |
Both admin_api_key and admin_api_key_sha256 set |
Digest wins; raw ignored; startup warning. |
Raw admin_api_key set (no digest) |
Works, but logs a deprecation warning. |
- peer-onboarding.md — the guide to hand a peer who receives a token.
- remote-access-sharing.md — exposing only
/eventsto external peers. - service-setup.md — running the server as a system service.
server-config.example.yaml— annotatedapi_key/api_keysreference.docs/designs/per-user-api-keys.md— the design record behind this model.