Security
This page is for operators securing a Dakera v0.12.3 deployment. It covers authentication and permissions, encryption at rest and key rotation, cluster and gRPC authentication, CORS, rate limits, input validation, audit logging, and a production checklist.
Every request passes through rate limiting, authentication and input validation before it reaches the engine. Memory content can be encrypted at rest with AES-256-GCM, and every request is written to an audit log.
Security layers
| Layer | Protection | Configuration |
|---|---|---|
| Encryption at rest | AES-256-GCM, values bound to their record, rotating keyring | DAKERA_ |
| API authentication | Four key scopes, optional namespace restriction | DAKERA_ |
| Input validation | Refused patterns, field and size limits | Built in, always on |
| Security headers | CORS, CSP, HSTS, X-Frame-Options and others | DAKERA_HSTS_*, DAKERA_CSP_*, DAKERA_, DAKERA_ |
| Rate limiting | Server-wide token bucket, plus optional per-namespace limits | DAKERA_, DAKERA_ |
| Cluster authentication | Shared secret on node-to-node routes, HMAC-authenticated gossip; required on every cluster node (v0.12.1) | DAKERA_ |
| Audit logging | One entry per REST request and gRPC call | DAKERA_AUDIT_* |
Input validation
Validation is always on. A value that matches one of these patterns is refused with 400, and the message names the rule it broke:
| Pattern | Where it is refused |
|---|---|
$where, $expr, $function | Names and text (MongoDB-style operators) |
javascript: | Names and text |
<script | Names and text |
| A NUL byte | Names and text |
../ or ..\ (path traversal) | Names only (ids and metadata keys). Text such as metadata values, tags and filter values may contain a relative path |
Filters accept only the documented operators. Outbound URLs that a caller can choose (such as extractor_override.base_url) are checked against private and reserved address ranges; see Limits and hardening.
Field limits
| Field | Limit |
|---|---|
| Request body | DAKERA_, default 10 MiB (413 above it) |
| Memory content | DAKERA_, default 100,000 UTF-8 bytes, on every memory write path (configurable from v0.12.2; updates are checked too) |
| Tags per memory | 50, each at most 256 characters |
| Agent ID | 241 bytes (v0.12.2; was 256), so _ fits one path segment |
| Session ID | 256 characters |
| Namespace name | 128 characters: letters, digits, underscore and hyphen, starting with a letter or digit |
| Vector ID | 512 characters |
| Metadata | 100 fields per record; keys up to 256 characters; values up to 32,768. Memory metadata (v0.12.2): a JSON object or null, keys 1 to 256 bytes, at most the content limit serialized, and no _dakera_* key other than _ and _dakera_lang |
| Key grants | At most 100 entries of at most 255 bytes (v0.12.2) |
| Filter nesting | 10 levels, 50 conditions |
top_k | 1 to 10,000 |
| Vectors per upsert | 10,000 |
| API key name | 128 characters |
Security headers
Dakera sets these response headers by default (enabled unless noted):
| Header | Default value | Setting |
|---|---|---|
Strict- |
max- |
DAKERA_ (default true), DAKERA_ (seconds) |
Content- |
default- |
DAKERA_ (default true), DAKERA_ |
X-Frame-Options |
DENY |
DAKERA_: DENY, SAMEORIGIN, or empty to disable. Any other value is an invalid value (a fresh install refuses to start) |
X- |
nosniff |
always on |
X- |
1; mode=block |
always on |
Referrer-Policy |
strict- |
always on |
Permissions- |
geolocation=(), microphone=(), camera=() |
always on |
Dakera does not terminate TLS; set HSTS at the reverse proxy as well if it terminates TLS for you. There are no preset names to select: the headers above are what the environment variables configure.
export DAKERA_HSTS_MAX_AGE=63072000
export DAKERA_FRAME_OPTIONS=SAMEORIGIN
CORS and cross-origin protection
| Situation | Behavior |
|---|---|
Authentication on, DAKERA_ unset |
Any origin may call the API from a browser; credentials are never allowed in this case |
DAKERA_ |
Only the listed origins |
Authentication off (DAKERA_), DAKERA_ unset |
Loopback origins only (localhost, 127.0.0.1, [::1]). A POST, PUT, PATCH or DELETE from a web page of another origin (an Origin that is neither loopback nor listed, or Sec-) is refused with 403 CROSS_ |
DAKERA_ with no origins or * |
Ignored with a startup warning: credentials are only sent when every allowed origin is named |
Clients that send no Origin header (SDKs, curl, servers) and GET, HEAD and OPTIONS requests are never refused by the cross-origin check.
TLS and HTTPS
Dakera does not terminate TLS. Terminate it at a reverse proxy or load balancer (Nginx, Caddy, Traefik or a cloud load balancer) in front of the REST and gRPC ports.
See Deployment → Reverse proxy & TLS termination for complete Nginx and Caddy configurations. For gRPC, configure mTLS at the proxy level when client certificate authentication is required.
Encryption at rest
With DAKERA_ENCRYPTION_KEY set, memory content, text documents (_text) and the persisted full-text (BM25) indexes are sealed with AES-256-GCM before they reach storage. The write-ahead log, snapshots, RocksDB files, the disk cache, Redis, S3 objects, backups and cluster replication therefore only carry ciphertext. Nothing else needs configuring and nothing changes for clients: the memory API returns plaintext.
| Setting | Value |
|---|---|
DAKERA_ |
64 hex characters (a raw 256-bit key, openssl rand -) or a passphrase of at least 8 characters (derived with PBKDF2-HMAC-SHA256). The same value on every node of a cluster. |
| Sealed form | $enc$v2$<key_ (AES-256-GCM). The key id is 16 hex characters derived one way from the key. |
| Binding | Each value is authenticated together with its namespace, record id and field, so a sealed value copied into another record, namespace or field does not decrypt there. |
| Older values | $enc$v1$ values written by v0.11 keep decrypting and are re-sealed to $enc$v2$ by a background pass at startup (values_ in the status). |
Keyring
The key in DAKERA_ENCRYPTION_KEY is the first data key. Every rotation adds a data key to the keyring, which is stored in the reserved namespace _dakera_keyring, wrapped under a master key derived from DAKERA_ENCRYPTION_KEY (never in the clear), and replicated to every node. Old keys are kept, so values sealed under them keep opening while they are re-sealed and backups taken before a rotation still restore. A key that nothing references any more is marked retired but is still kept for old backups, WAL segments and snapshots. Key material never appears in responses, errors, logs or debug output; only key ids do.
Rotating keys
POST /admin/encryption/rotate-key (global admin scope; /v1/admin/encryption/rotate-key works too).
| Field | Meaning |
|---|---|
namespace |
Rotate only this namespace: the new key seals it (its memories' content, text documents and BM25 index) and nothing else. Omitted: the global default is rotated and every namespace, including those rotated on their own, moves to the new key. The namespace must exist and hold data a key can seal. |
new_key |
Optional passphrase (at least 8 characters) or 64 hex characters the new key is derived from. Omitted: the server generates a random 256-bit key. A shorter passphrase is a 400 INVALID_. |
wait_secs |
How long to wait for the re-seal before answering. Default 10, at most 300, 0 answers at once. The re-seal continues in the background either way. |
# One namespace; every other namespace keeps its key
curl -X POST localhost:3000/admin/encryption/rotate-key \
-H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{"namespace": "_dakera_agent_alice"}'
# Everything
curl -X POST localhost:3000/admin/encryption/rotate-key \
-H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" -d '{}'
The response reports key_id, previous_key_id, scope (global or namespace), namespace, reseal (completed, running, or partial: work the background pass retries, such as records written meanwhile or a namespace that could not be read), rotated (values re-sealed by the time of the answer), skipped, namespaces, fulltext_indices_rotated, failed_namespaces, action_required and status_url. Reads keep working throughout. DAKERA_ENCRYPTION_KEY itself does not change after a rotation, and every node re-seals its own copy of the data after receiving the new key through replication.
Status, re-seal and master revocation
| Route | Scope | What it does |
|---|---|---|
GET / |
global admin |
Key ids, which key seals which namespace (namespace_keys), retirement state, this node's re-seal pass with live counters (reseal), every node's last complete pass (nodes). Never key material. enabled: false when encryption is off. |
POST / |
global admin |
Runs a re-seal pass now. Body {"namespace"?, "wait_, default wait_secs 0. 200 {"pass": ...} when the pass finished within the wait, 202 {"reseal": "running", "status_ otherwise, 400 when encryption is not configured. A pass also runs on its own after every rotation, keyring change and restore. |
POST / |
global admin |
Revokes the master of a retired key that was rotated in from a passphrase, so that passphrase no longer opens the keyring. The data key is kept, so backups taken under it still restore. 409 while the key still seals data or is not retired. |
Metrics: dakera_encryption_values_resealed_total{kind}, dakera_encryption_values_unreadable, dakera_encryption_reseal_running, dakera_encryption_reseal_namespaces_remaining, dakera_encryption_deferred_writes_total and dakera_encryption_keys{state}. A shipped alert rule fires on unreadable encrypted values (see Operations).
Changing DAKERA_ENCRYPTION_KEY itself
- Rotate with
new_keyset to the new value. The keyring is then also wrapped under it. - Set
DAKERA_ENCRYPTION_KEYto the new value on every node and restart them. - Once the key that came from the old passphrase is retired, optionally revoke its master with
revoke-master.
Setting a new value without step 1 leaves the keyring, and everything sealed under its keys, unreadable. If encryption at rest is off, a global rotation with new_key turns it on for the running process and the response carries action_required: set DAKERA_ENCRYPTION_KEY to that value before the next restart.
What still holds old ciphertext after a rotation
Until rewritten or expired: WAL segments and snapshots written before the re-seal, RocksDB SST files until compaction, S3 object versions when bucket versioning is on, the disk and Redis caches until the entry is replaced, and backups. All of it stays decryptable with the keyring and none of it is plaintext.
Encrypted backups
POST /admin/backups accepts "encrypt": true (every file of the backup sealed with AES-256-GCM under a key derived from the operator key) and "compression": "zstd"; compression is applied first, then encryption, and both are recorded in the manifest. encrypt without DAKERA_ENCRYPTION_KEY, and "compression": "lz4", are refused with 400. Backups also carry _text sealed (v0.11 backups held it in plaintext). Details: Operations.
Going back to v0.11
dakera downgrade opens every value with the persisted keyring (generated keys, passphrase keys and namespace keys included) and re-seals memory content as $enc$v1$ under DAKERA_ENCRYPTION_KEY, with _text and the BM25 indexes in plaintext, which is what v0.11.108 reads. The keyring is kept, so backups taken under v0.12 still restore with v0.12. Values it cannot open (a key this DAKERA_ENCRYPTION_KEY cannot unwrap) are counted in values_unreadable and make the command exit 1. See Rolling back to v0.11.
Verify
curl -s localhost:3000/admin/encryption/status -H "Authorization: Bearer $ADMIN_KEY" | jq '{enabled, default_key_id, reseal}'
curl -s localhost:3000/metrics | grep dakera_encryption_values_unreadable # expect 0
API key authentication
Send the key as Authorization: Bearer <key> or X-API-Key: <key>. DAKERA_ROOT_API_KEY bootstraps a super_admin key; with authentication on (the default) and no key configured, the server exits at startup.
Scope hierarchy
| Scope | Permissions | Use case |
|---|---|---|
read | Query vectors, recall memories, get namespace info | Read-only clients |
write | Upsert/delete vectors, store/forget memories (includes read) | Applications writing data |
admin | Namespace management, policies, /admin/* and /ops/* (includes write) | Administrative tools |
super_admin | Key management (/admin/keys), backup download, upload and restore (includes admin) | Operators only |
403 on node-wide routes: /admin/*, /ops/*, /v1/analytics/*, /v1/audit*, /v1/kpis and /v1/events/stream need the scope with no namespace restriction. Backup download, upload and restore need global super_admin (a backup bundle carries every API key record). A namespace-pinned admin key may still delete, optimize, warm and rebuild its own namespaces. GET /v1/export and POST /v1/import need read / write on the agent's namespace, and POST /v1/namespaces/{ns}/keys with extra_namespaces only grants namespaces the calling key can reach. POST /ops/shutdown now really shuts the server down and needs a global admin key. Check namespace-pinned keys and backup tooling before upgrading — see Upgrading to v0.12.Key grants (v0.12.2)
A key's namespaces is its grant list: null or ["*"] reach every namespace, [] reaches none, and each entry is an exact namespace name or a prefix pattern p* with one trailing star. Patterns are strict: team-* reaches team-a but not team or team-, and a pattern never reaches a server-internal namespace. ["_dakera_agent_mlx-*"] lets a developer key create and use its own agents (POST /v1/agents).
- Validated. Junk entries (
"a**b?", blank,"*"mixed with names), internal namespaces and patterns that could cover them (_dakera_*,_*) are refused with400on create, edit andextra_namespaces. - Existing keys never widen. On a key created before v0.12.2, a
foo*entry stays a literal name that grants nothing, across restarts, renames and rotations, until its namespaces are saved again withPATCH. Such entries, and a_dakera_sessionsentry, are listed ininert_namespaces. - Edit in place.
PATCH /admin/keys/{key_id}(super_admin) renames a key or replaces its namespaces;PATCH /v1/namespaces/{ns}/keys/{key_id}lets a namespaceadmindo the same within its own grants (ateam-*admin can grantteam-a*, nevert*,*ornull).scopecannot be changed. - Sessions are authorized by their agent's namespace alone; keys no longer need a
_dakera_sessionsgrant. - Open event streams re-check the key's scope and namespaces every 15 s and close when it no longer qualifies. Deactivation is permanent: no concurrent edit or late replicated add revives a deactivated key.
- Check a key with
GET /v1/auth/whoami: it answers the key as the server reads it (scope, namespaces,unrestricted,expires_at,grants_version,inert_namespaces), never the key itself.
API key lifecycle
The complete workflow from bootstrap to rotation:
# 1. Bootstrap root key (set at server start)
export DAKERA_ROOT_API_KEY=$(openssl rand -hex 32)
# 2. Create a scoped application key
curl -X POST http://localhost:3000/admin/keys \
-H "Authorization: Bearer $DAKERA_ROOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"query-service","scope":"read","expires_in_days":90}'
# {"key_id":"dk_key_1a2b3c4d","key":"dk_...","name":"query-service","scope":"read","namespaces":null,
# "created_at":1790000000,"expires_at":1797776000,"warning":"..."} (the key is shown only once)
# 3. Restrict key to a namespace (multi-tenant isolation)
curl -X POST http://localhost:3000/admin/keys \
-H "Authorization: Bearer $DAKERA_ROOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"tenant-a","scope":"write","namespaces":["tenant-a"],"expires_in_days":90}'
# 4. List all keys
curl http://localhost:3000/admin/keys \
-H "Authorization: Bearer $DAKERA_ROOT_API_KEY"
# 5. Monitor key usage (by key_id)
curl http://localhost:3000/admin/keys/dk_key_1a2b3c4d/usage \
-H "Authorization: Bearer $DAKERA_ROOT_API_KEY"
# 6. Rotate a key, keeping the old one valid for an hour while clients move (v0.12.2)
curl -X POST http://localhost:3000/admin/keys/dk_key_1a2b3c4d/rotate \
-H "Authorization: Bearer $DAKERA_ROOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"grace_secs":3600}'
# {"new_key":"dk_...","key_id":"dk_key_...","old_key_id":"dk_key_1a2b3c4d","old_key_expires_at":1791395803,...}
# 7. Or revoke a compromised key at once
curl -X DELETE http://localhost:3000/admin/keys/dk_key_1a2b3c4d \
-H "Authorization: Bearer $DAKERA_ROOT_API_KEY"
expires_in_days on application keys and rotate them on a schedule.Namespace isolation
Namespaces are the isolation boundary in multi-tenant deployments. A key restricted to namespace tenant-a can read and write only that namespace, and namespace listings show it only the namespaces it can reach.
| Isolation property | Guarantee |
|---|---|
| Data isolation | Memories, vectors, entities, and KG nodes are namespace-scoped |
| Key isolation | Namespace-scoped keys cannot access other namespaces |
| Index isolation | Each namespace has independent HNSW/BM25 indexes |
| Policy isolation | Memory policies (TTL, decay, deduplication, rate limits) are set per namespace |
# Create an isolated namespace for a tenant
curl -X POST http://localhost:3000/v1/namespaces \
-H "Authorization: Bearer $DAKERA_ROOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"tenant-a","dimension":1024}'
# Create a key scoped to that namespace
curl -X POST http://localhost:3000/admin/keys \
-H "Authorization: Bearer $DAKERA_ROOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"tenant-a-app","scope":"write","namespaces":["tenant-a"]}'
Permissions
Keys have one of four scopes (read, write, admin, super_admin, each including the one before) and may be restricted to namespaces. In v0.12.0 a key restricted to namespaces no longer passes checks that only asked for a scope. With authentication off every request is super_admin and none of this applies.
| Route | Needs |
|---|---|
POST /, POST /, GET / |
super_admin with no namespace restriction (a bundle carries every API key record) |
Every other /admin/* and /v1/admin/* route (backups create, list, get, delete, schedule; restore status; encryption; cluster) |
admin with no namespace restriction. Exceptions a namespace-pinned admin key may still call for its own namespaces: DELETE /, POST /, POST /, POST / |
/admin/keys/* |
super_admin with no namespace restriction |
/, /ops/jobs, /ops/jobs/{id}, /ops/compact, /ops/events, /v1/ops/metrics, /debug/config |
admin with no namespace restriction (/v1/ops/stats stays read and shows a pinned key its own namespaces) |
POST / |
super_admin with no namespace restriction (v0.12.3; a shutdown stops the node for every tenant) |
/v1/analytics/*, /v1/audit, /, /, /v1/kpis, /, POST / |
admin with no namespace restriction |
GET /v1/export, POST /v1/import |
read (export) / write (import) on the agent's namespace _ |
GET / |
access to the agent's namespace as well |
Sessions: POST /, /end, /touch, GET /, GET / (v0.12.2) |
the scope on the session's agent namespace only (no _ grant). end and touch need Write scope, checked before the session is looked up |
POST /v1/agents (v0.12.2) |
write on _, so a pattern key works within its prefix |
PATCH / (v0.12.2) |
admin on {ns}; the new grants must be contained in the caller's
|
POST /, PUT /, GET / |
the scope on that namespace |
POST / with extra_ |
only namespaces the calling key can reach |
Check namespace-pinned keys and backup tooling before upgrading (Upgrading to v0.12).
gRPC
With authentication on (the default), every RPC except Health needs an API key in the call metadata (x-api-key: <key> or authorization: Bearer <key>), authorized per scope and namespace like its REST twin (UNAUTHENTICATED, PERMISSION_DENIED). Calls draw on the server-wide rate limit (RESOURCE_EXHAUSTED), are bounded by DAKERA_REQUEST_TIMEOUT and, for Query, query_timeout_ms (DEADLINE_EXCEEDED), fail UNAVAILABLE while maintenance mode rejects requests, and are audited.
Cluster authentication
Cluster mode requires DAKERA_CLUSTER_SECRET (at least 16 characters, identical on every node) and a stable DAKERA_CLUSTER_NODE_ID. Generate the secret with openssl rand -hex 32 and store it like any other credential. It authenticates; it does not encrypt — keep node-to-node traffic on a private network or behind TLS.
- Every node-to-node request under
/internal/*must carry the secret. On a node that is not in cluster mode these routes answer404. - Gossip packets carry an HMAC-SHA256 tag over a send time and a random nonce. An untagged or forged packet is dropped, as is one more than 5 minutes away from the receiver's clock or whose nonce was already seen.
- From v0.12.1 a cluster node without the secret refuses to start, fresh install or upgraded: it exits with code 78 before it serves anything, and
dakera --check-configreports a missing or short secret. On v0.12.0 a node upgraded from v0.11 could start without it, with the node-to-node routes open as in v0.11:POST /internal/sync/key/addthen let anyone who reached the API port mint asuper_adminkey. Set the secret on every node and upgrade them in one window (High availability); rotating it needs the same window. - A node upgraded from v0.11 without a node id generates one once, keeps it in
{data root}/cluster-node-id, and isdegradedin/health(cluster_auth) until it is set. - Cluster nodes must not share one S3 bucket (
shared_store; see High availability).
Limits and hardening
| Area | v0.12.0 behavior |
|---|---|
| Sealed values from clients | A vector write whose _text or content metadata starts with $enc$v1$ or $enc$v2$ is 400 (gRPC INVALID_). Memory content that merely starts with those tags is stored as written. Memory content is opened only under the namespace it was read from |
| Importing a v0.11 export | An encrypted v0.11 export carries $enc$v1$ content; import it with a key of global admin or super_admin scope. A $enc$v2$ value is refused from anyone |
| Raw vector APIs | query, batch query, export, records and gRPC Query return a memory's content in its at-rest form (z64: or $enc$v2$); read memories through the memory API for plaintext |
| Knowledge graph | Edges are keyed by namespace, so one agent cannot overwrite or delete another agent's edges. POST / is 404 unless both ids are live memories of the agent. Path searches stop at 10 hops or 4096 memories (400) |
| Sessions | POST / with another agent's session id is 409; GET / needs access to the agent |
| Inputs | Imported content is limited to 100,000 bytes per item; an uploaded backup bundle inflates to at most 16 times DAKERA_; extractor responses are capped at 4 MiB; namespace event streams are capped at 128 per key; non-finite vectors are refused |
| SSRF | A caller-chosen extractor_ is not fetched through the system proxy. The ranges 198.18.0.0/15, the documentation networks and 64:ff9b:1::/48 are refused destinations |
| Secrets | API keys passed as ?api_key= are redacted in the audit log; the HF token and the peer secret do not follow a cross-host redirect; the S3 secret is not logged; /health shows no filesystem paths or credential URLs in degraded reasons |
| Reserved namespaces | Server-internal namespaces (the keyring _dakera_keyring, API keys, namespace configs, quotas, full-text snapshots, sessions, replication bookkeeping, tiered-storage bookkeeping and the store metadata that holds the embedding-model record) are not client-writable: a plain vector write into them is refused. Client namespace names may not begin with system_, internal_, admin_ or _ (other than _dakera_... names) |
| Request body size | DAKERA_ (default 10 MiB); an over-size body is 413 PAYLOAD_ |
| Rate limit | DAKERA_ (default 100) and DAKERA_ (default 50) per node; over the limit is 429 RATE_ with Retry-After and X-RateLimit-* headers |
| Namespace quotas | Enforced on every write path: a write over a hard quota is 413 QUOTA_ |
| Connections (v0.12.3) | A client has DAKERA_ (default 30 s) to send a request head; an idle keep-alive or HTTP/2 connection is closed after the same time; at most DAKERA_ (default 10000) connections are served at once; the graceful shutdown drains for at most DAKERA_ (default 8 s) |
| DNS rebinding, authentication off (v0.12.3) | A request whose Host is a dotted name not listed in DAKERA_ is refused with 421 HOST_. localhost, IP addresses, single-label names, private names such as *.internal and *.local, and /health* are always served. No effect with authentication on |
| Admin request bodies (v0.12.3) | POST /, POST /, POST / and POST / answer 422 to a field they do not know, so a misspelled field can no longer weaken a key or a backup. Data-plane requests stay lenient and report ignored fields as warnings |
| Document import (v0.12.3) | PDF and DOCX files are parsed in a sandboxed child process with an empty environment and time, memory, CPU and file limits; a malformed file or an archive bomb fails its import (422, 504 EXTRACTION_) and never the server |
Telemetry
Telemetry is on by default. It sends lifecycle events and a 6-hourly heartbeat, including the machine's hostname; PostHog also records the connecting IP address and derives a coarse location (GeoIP). It never sends memory content, queries, ids, keys or paths. Turn it off with DAKERA_TELEMETRY=0 or DO_NOT_TRACK=1. Full list: Telemetry.
Rate limiting
Dakera enforces two kinds of limit. The server-wide limit is a token bucket per node; with DAKERA_REDIS_URL set, the nodes share one counter in Redis (falling back to the per-node bucket if Redis does not answer). Per-namespace limits are optional and per minute: set rate_limit_enabled, rate_limit_stores_per_minute and rate_limit_recalls_per_minute in the namespace's memory policy (PUT /v1/namespaces/{ns}/memory_policy).
# Global rate limit
DAKERA_RATE_LIMIT_RPS=100 # requests per second (default; RATE_LIMIT_RPS still works, deprecated)
DAKERA_RATE_LIMIT_BURST=50 # burst capacity
gRPC calls take a token from the same limit.
When rate limited, clients receive a 429 Too Many Requests response with Retry-After header indicating when to retry.
# Example rate-limited response (also sends X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset)
HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/json
{"error":"Rate limit exceeded: 100 requests per second for this server. Resets at Unix time 1790000000.","code":"RATE_LIMIT_EXCEEDED","status":429,"details":"remaining: 0"}
Audit logging
Dakera writes one audit entry per request to the log (target audit), at a level that follows the status code. The entry records the key id, never the key.
Configuration
| Variable | Default | Effect |
|---|---|---|
DAKERA_ |
true |
Turns the audit log on or off |
DAKERA_ |
json |
json or text (key=value lines). Anything else is an invalid value |
DAKERA_ |
false |
Record request bodies |
DAKERA_ |
false |
Record response bodies |
DAKERA_ |
4096 |
Bytes of a body kept; longer bodies are cut |
DAKERA_ |
/ |
Comma-separated paths not logged (setting it replaces the default list). OPTIONS requests are never logged |
Body capture runs after authentication, so a rejected request is never read. Bodies are capped, secrets are redacted, and the bodies of key-management and cluster routes (/admin/keys, /admin/cluster, /v1/namespaces/{ns}/keys, and routes that carry keys) are always recorded as [REDACTED]. Secret query parameters such as ?api_key= are logged as [REDACTED]. Enabling capture grows the audit log.
Audit entry
{
"request_id": "7f3c9a1e-00000042",
"timestamp": "2026-10-01T10:30:45.123Z",
"method": "POST",
"path": "/v1/memory/store",
"client_ip": "10.0.1.50",
"user_agent": "curl/8.5.0",
"api_key_id": "dk_key_1a2b3c4d",
"status_code": 200,
"duration_ms": 45,
"request_size": 412,
"response_size": 96
}
request_id is the client's X-Request-ID when it sends one. Optional fields (query, client_ip, user_agent, api_key_id, request_size, response_size, request_body, response_body, error, context) appear only when they have a value. gRPC calls are audited as well.
Security monitoring
Monitor audit logs for:
- Failed authentication attempts (401). The server logs a request without a valid key at DEBUG with at most one WARN per minute counting them;
dakera_errors_total{type="auth"}counts every one - Forbidden access attempts (403)
- Rate limit hits (429)
- Admin operations outside maintenance windows
- Access from unexpected IP ranges
Container security
| Practice | Implementation |
|---|---|
| Non-root user | The official image runs as the dakera user, UID 1000 |
| Read-only root filesystem | A default start writes nothing outside its volumes: /data (the data root) and /app/models (the cache for models the image does not ship) |
| Minimal base | From v0.12.3 a distroless image with no curl and no package manager; the health check is dakera healthcheck, built into the server binary. The GPU image (CUDA 13) runs as the same non-root user |
| Signed images | From v0.12.3 release images are signed keylessly with Sigstore cosign and carry SLSA provenance and an SPDX SBOM (see below) |
| Kubernetes security context | Set runAsNonRoot: true, readOnlyRootFilesystem: true and drop all capabilities |
# Kubernetes security context
securityContext:
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
Verifying release images (v0.12.3)
Every release tag (:<version>, :cpu, :latest, :gpu, :cuda) is signed by the release workflow, and the signature is recorded in the public Rekor transparency log. Verify an image with cosign v3 or later before you run it (cosign 2.x does not find these signatures), and read its provenance and SBOM:
cosign verify ghcr.io/dakera-ai/dakera:0.12.3 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github\.com/(?i:dakera-ai)/dakera/\.github/workflows/release\.yml@refs/tags/v'
docker buildx imagetools inspect ghcr.io/dakera-ai/dakera:0.12.3 --format '{{ json .Provenance }}'
docker buildx imagetools inspect ghcr.io/dakera-ai/dakera:0.12.3 --format '{{ json .SBOM }}'
Images published before v0.12.3 are unsigned.
Network security
| Port | Service | Exposure |
|---|---|---|
3000 | REST API | Through a reverse proxy only; never expose it directly to the internet |
50051 | gRPC API | Internal services only, or through a gRPC-aware proxy that terminates TLS |
7946 | Gossip (SWIM) | UDP, between cluster nodes only. Replication and elections use the REST port (/internal/*), so allow it between nodes too |
# UFW firewall rules for a Dakera node
sudo ufw allow from any to any port 443 proto tcp # HTTPS via reverse proxy
sudo ufw allow from 10.0.0.0/8 to any port 3000 # REST API from internal network
sudo ufw allow from 10.0.0.0/8 to any port 50051 # gRPC from internal network
sudo ufw allow from 10.0.0.0/8 to any port 7946 proto udp # Gossip between cluster nodes
sudo ufw deny 3000 # Block external REST access
sudo ufw deny 50051 # Block external gRPC access
sudo ufw deny 7946 # Block external gossip access
Production security checklist
| Item | Priority |
|---|---|
| Root API key with 256-bit+ entropy | Required |
| Scoped application keys (least privilege) | Required |
| TLS on all external endpoints (reverse proxy) | Required |
| Server behind reverse proxy (never expose port 3000 publicly) | Required |
| Firewall: only expose 443 externally | Required |
Encryption at rest (DAKERA_) | Recommended |
| CORS restricted to application domains | Recommended |
| Rate limiting tuned for workload | Recommended |
| Audit logging enabled | Recommended |
| Application keys with an expiry and a rotation schedule | Recommended |
| API keys on every gRPC client (v0.12) | Required |
DAKERA_ (at least 16 characters, identical) and a stable DAKERA_ on every node of a cluster (v0.12; a node without the secret does not start from v0.12.1) | Required |
| Namespace-pinned keys and backup tooling checked against the v0.12 permission table | Required when upgrading |
Encryption key rotation schedule and GET / check | Recommended |
| Shipped Prometheus alert rules loaded (Operations) | Recommended |
Telemetry opt-out decided (DAKERA_ / DO_NOT_TRACK=1) | Review |
| Namespace-scoped keys for multi-tenant deployments | Recommended |
| Container runs as non-root with read-only filesystem | Recommended |
Related: Deployment guide (reverse proxy, TLS, firewall setup) · Architecture (trust boundaries, storage layers) · Self-hosted deployment walkthrough
Included in the self-hosted server
Encryption at rest, namespace-scoped API keys, audit logging and Prometheus metrics are part of the free self-hosted binary.