Security
Security
SapixDB is a self-contained database — no external auth service required. Record payloads are encrypted at rest with AES-256-GCM. A root key bootstraps your deployment. Scoped API keys give each service exactly the access it needs. Codios adds per-operation receipts on top.
The Three Layers
These layers stack. An agent calling SapixDB goes through all three. A human admin in the Control Plane goes through Layer 2. An external application goes through Layer 2. Every layer is optional — SapixDB starts in open mode if nothing is configured — but all three should be active in production.
Layer 1 — Root Key
The root key is your operator credential. It grants unconditional access to everything including /v1/admin/*. Generate it once, store it in a password manager, use it only to create scoped keys.
# Generate openssl rand -hex 32 # → a3f9c2e1b4d7f8a2c5e0b1d3f6a9c4e7b2d5f8a1c4e7b0d3f6a9c2e5b8d1f4 # Set on SapixDB service (Railway, Docker, k8s) SAPIX_ROOT_KEY=a3f9c2e1b4d7...
sapix_sk_... keys.Layer 2 — Scoped API Keys
Every service, app, or integration gets its own named API key. Keys are stored hashed in the _api_keys system agent and loaded into memory at startup for O(1) lookup on every request.
Key format
Keys are formatted as sapix_sk_ followed by 64 hex characters (32 random bytes, 256 bits of entropy). The secret is shown once at creation — only its SHA-256 hash is stored. Save it immediately.
Scopes
Every key has a list of scope strings. Each scope is verb:resource-glob. A request is allowed when any scope in the key's list matches the request's required verb and resource.
| Scope | What it grants |
|---|---|
admin:* | Everything — all routes including key management (root key only) |
admin:api-keys | Create / revoke / list API keys (for key-issuing services) |
write:* | Write to any agent, strand, blob, graph edge |
read:* | Read from anything — strand, query, NL query, graph, blobs |
write:agents/orders/* | Write only to the orders agent |
read:agents/boboyka::* | Read from any agent in the boboyka organism |
write:agents/hire::* | Write to any hire organism agent |
["write:agents/orders/*", "read:agents/orders/*"].admin:* implies everything and is the only superscope.Creating a key
curl -X POST https://<sapixdb-url>/v1/admin/api-keys \
-H "Authorization: Bearer $SAPIX_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"label": "agent-database-boboyka-prod",
"scopes": ["write:agents/boboyka::*", "read:agents/boboyka::*"],
"caller_id": "agt_database_prod",
"rate_limit_rps": 500
}'{
"key_id": "kid_a3f9c2e1b4d7f8a2",
"key": "sapix_sk_a3f9c2e1b4d7f8a2c5e0b1d3f6a9c4e7...",
"key_prefix": "sapix_sk_a3f9…",
"label": "agent-database-boboyka-prod",
"scopes": ["write:agents/boboyka::*", "read:agents/boboyka::*"],
"created_at_ms": 1748304000000,
"expires_at_ms": null,
"caller_id": "agt_database_prod",
"rate_limit_rps": 500,
"warning": "Save this key immediately — it will not be shown again."
}Using a key
Send every request with Authorization: Bearer sapix_sk_.... Optionally include X-Sapix-Caller to override the Codios caller identity.
import { SapixClient } from "@sapixdb/sdk";
const db = new SapixClient({
url: process.env.SAPIX_API_URL,
apiKey: process.env.SAPIX_API_KEY, // sapix_sk_... scoped key
callerId: "my-service", // optional — forwarded to Codios
});from sapixdb import SapixClient
db = SapixClient(
url=os.environ["SAPIX_API_URL"],
agent="my-app",
api_key=os.environ["SAPIX_API_KEY"],
caller_id="my-service",
)curl -H "Authorization: Bearer sapix_sk_..." \
-H "X-Sapix-Caller: my-service" \
https://<sapixdb-url>/v1/agents/orders/records/json \
-d '{"data": {"order_id": "123", "status": "paid"}}'Managing keys
curl -H "Authorization: Bearer $SAPIX_ROOT_KEY" \ https://<sapixdb-url>/v1/admin/api-keys
curl -X DELETE \ -H "Authorization: Bearer $SAPIX_ROOT_KEY" \ https://<sapixdb-url>/v1/admin/api-keys/kid_a3f9c2
curl -X PATCH \
-H "Authorization: Bearer $SAPIX_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{"scopes": ["read:*"], "rate_limit_rps": 100}' \
https://<sapixdb-url>/v1/admin/api-keys/kid_a3f9c2curl -H "Authorization: Bearer $SAPIX_ROOT_KEY" \
https://<sapixdb-url>/v1/admin/api-keys/kid_a3f9c2/usage
# → { "request_count_since_restart": 14203, "last_used_at_ms": 1748394512000 }Rate Limiting
Set rate_limit_rps on any key to enforce a per-second request limit. SapixDB uses a token bucket per key — each request consumes one token, tokens refill atrate_limit_rps per second. Excess requests get a 429 response.
HTTP 429 Too Many Requests
{
"error": "rate_limited",
"message": "Key 'external-app' exceeded its rate limit of 100 req/s."
}rate_limit_rps: null (the default) means unlimited — appropriate for trusted internal services on a private network.
Key Delegation — Multi-Tenant Apps
If you're building an application on SapixDB and need per-user or per-tenant data isolation, your backend can issue scoped keys for each user. Give your backend service the admin:api-keys scope and it can create child keys — but only with scopes it already has.
// Your backend server has: ["write:agents/myapp::*", "read:agents/myapp::*", "admin:api-keys"]
const res = await fetch(`${SAPIX_URL}/v1/admin/api-keys`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAPIX_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
label: `user-${userId}`,
scopes: [
`write:agents/myapp::${userId}/*`,
`read:agents/myapp::${userId}/*`,
],
expires_at_ms: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days
rate_limit_rps: 50,
}),
});
const { key } = await res.json();
// Return 'key' to the user — they now access only their own dataadmin:api-keysscope cannot grant scopes it doesn't have. If your backend has write:agents/myapp::*, it cannot issue a child key withwrite:agents/other-app::*. SapixDB enforces the boundary — no trust required.HTTP API Reference
| Method | Path | Required scope | Description |
|---|---|---|---|
| POST | /v1/admin/api-keys | root OR admin:api-keys | Create key — secret shown once |
| GET | /v1/admin/api-keys | root OR admin:* | List all keys |
| GET | /v1/admin/api-keys/:id | root OR admin:* | Get one key |
| DELETE | /v1/admin/api-keys/:id | root OR admin:* | Revoke key |
| PATCH | /v1/admin/api-keys/:id | root OR admin:* | Update label / scopes / expiry / rate |
| GET | /v1/admin/api-keys/:id/usage | root OR admin:* | Usage stats since last restart |
At-Rest Encryption
SapixDB encrypts every record payload in segment files with AES-256-GCMso that raw filesystem access — stolen disk, leaked backup, cloud storage exposure — yields no readable data. The WAL (write-ahead log) stays plaintext for cold-start resilience but is automatically deleted after each server restart or checkpoint.
Enabling encryption
Set SAPIX_MASTER_SEED to a 64-hex-character string (32 bytes). This single secret drives all key derivation — keypairs, per-agent encryption keys, and organism agent seeds. Generate it once:
openssl rand -hex 32 # → deadbeefdeadbeef... (64 hex chars — exactly this format) # Set on your SapixDB service SAPIX_MASTER_SEED=deadbeef...64chars...
mysecretkey is silently ignored — the server starts in unencrypted mode with no warning to the caller. Always use openssl rand -hex 32 to generate the seed.How it works
One AES-256-GCM key is derived per agent using HKDF-SHA256:
Each record gets a fresh 12-byte random nonce. On-disk layout inside the segment:
nonce (12 bytes, random per record) ciphertext (plaintext_len bytes, AES-256-GCM) auth tag (16 bytes, GCM authentication)
The content_hash header field is always BLAKE3(plaintext) — chain integrity and Ed25519 signatures are unaffected by encryption. API callers always receive decrypted payloads; the ENCRYPTED flag is cleared before the response is sent.
What is and isn't encrypted
| Location | Encrypted? | Why |
|---|---|---|
Segment files (.sapx) — user agents | Yes | All application record payloads |
WAL file (strand.wal) | No — deleted after use | Cold-start resilience; deleted after replay or checkpoint |
System agents (_api_keys, _crons, etc.) | No | Operational metadata only; no user payload data |
Block header (content_hash, timestamps, signatures) | No | Needed for chain verification and indexing |
Flushing plaintext from disk
After writing records, a small plaintext WAL window exists until the next restart or explicit checkpoint. Call this endpoint to seal and delete all WAL files immediately:
curl -X POST https://<sapixdb-url>/v1/control/checkpoint
# → { "status": "ok", "agents_checkpointed": 12 }Behavior with wrong or missing seed
| Scenario | Outcome |
|---|---|
Correct SAPIX_MASTER_SEED | Records decrypt normally on every read |
Wrong SAPIX_MASTER_SEED (right format) | HTTP 500 — AES-GCM authentication fails; no data exposed |
No SAPIX_MASTER_SEED, no SAPIX_KEYPAIR_SEED_HEX | Server refuses to start |
No SAPIX_MASTER_SEED, explicit keypair provided | Server boots; encrypted records return HTTP 500 on read |
SAPIX_MASTER_SEED in wrong format (not hex) | Silently ignored; data stored as plaintext — use openssl rand -hex 32 |
Open Mode (Development)
When neither SAPIX_ROOT_KEY is set nor any scoped keys exist, SapixDB runs in open mode — all requests pass through without authentication. This is appropriate for local development on a private machine.
WARN SapixDB starting in OPEN MODE — no SAPIX_ROOT_KEY and no scoped API keys.
Any caller that can reach this port can read and write data.
Set SAPIX_ROOT_KEY then use POST /v1/admin/api-keys to issue scoped keys.SAPIX_ROOT_KEY and issue scoped keys before exposing SapixDB to any network traffic. Private networking (Railway internal, k8s ClusterIP) reduces blast radius but does not replace auth.Layer 3 — Codios Contracts (Agent Operations)
Codios is optional but recommended for agent workloads. When SAPIX_CODIOS_URL is set, every write, read, query, cron execution, trigger fire, and Mutant operation calls Codios for a signed contract before executing. The contract includes:
- The SapixDB agent ID performing the operation
- The operation verb and resource
- The caller identity (key label from Layer 2, or
X-Sapix-Callerheader) - A per-request unique ID (prevents replay)
- A 5-second TTL
Codios denials return 403 Forbidden and emit a codios_denied A2A event. Set SAPIX_REQUIRE_CODIOS=true to make Codios mandatory — startup fails ifSAPIX_CODIOS_URL is absent.
{
"agent_id": "sapixdb-primary",
"operation": "write",
"resource": "agents/orders/records",
"caller_identity": "agent-database-boboyka-prod", // from API key label
"request_id": "req_a3f9c2e1",
"timestamp": 1748304512000,
"ttl_ms": 5000
}Production Quick Start
SAPIX_ROOT_KEY=$(openssl rand -hex 32) # Store this in your password manager — it's your master credential
BASE=https://<sapixdb-url>
# For agent-database
curl -s -X POST $BASE/v1/admin/api-keys \
-H "Authorization: Bearer $SAPIX_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"label": "agent-database-prod",
"scopes": ["write:agents/boboyka::*", "read:agents/boboyka::*"],
"caller_id": "<your Codios agent ID>"
}' | jq '{key_id, key}' # save the key value
# For Control Plane (admin GUI — server-side only, never in browser)
curl -s -X POST $BASE/v1/admin/api-keys \
-H "Authorization: Bearer $SAPIX_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{"label": "control-plane-prod", "scopes": ["admin:*"]}' | jq '{key_id, key}'# Each service gets its own key in SAPIX_API_KEY env var # agent-database Railway env: SAPIX_API_KEY=sapix_sk_<key from step 2> # Control Plane Vercel env (server-side): SAPIX_API_KEY=sapix_sk_<control-plane key from step 2>