quiver
Documentation menu

Security & API keys

With no API keys configured, authentication is off and anyone who can reach the ports can read and write. That is the default, and fine on 127.0.0.1, which is also the default bind address. Set keys before the server is reachable from anywhere else; it logs a warning when it listens on a non-loopback address without any.

Roles

RoleMay
readonlyevery read: list and get collections and points, count, search, recommend, hybrid queries, list aliases, list and download snapshots, /info and /metrics
readwriteeverything, including creating, changing, renaming and dropping collections, setting and deleting aliases, writes, snapshots, uploads and restores

GET /, GET /health and the gRPC Health call need no key. Give read-only keys to anything that only queries.

Configuring keys

Three sources, which combine:

quiver --keys-file keys.json
quiver --api-key "$WRITE_KEY" --readonly-api-key "$READ_KEY"
QUIVER_API_KEY=... QUIVER_READONLY_API_KEY=... quiver

The keys file is a JSON array of named keys, like keys.example.json:

[
  { "name": "admin", "role": "readwrite", "key": "replace-with-a-long-random-secret" },
  { "name": "dashboard", "role": "readonly", "key": "replace-with-another-secret" }
]

role is readwrite or readonly (read_write/write and read_only/read work too). The name only appears in the server's debug log. The server refuses to start if a key is empty or two keys are the same, across all three sources, or if the file has a field it does not know (so that a misspelt collections cannot silently grant access to everything). Use long random secrets, for example openssl rand -hex 32. Keys are compared in constant time.

Collection scopes

A key in the keys file can be limited to some collections with collections, a list of exact names and prefixes ending in *:

[
  { "name": "admin", "role": "readwrite", "key": "replace-with-a-long-random-secret" },
  {
    "name": "tenant-a",
    "role": "readwrite",
    "key": "replace-with-a-third-secret",
    "collections": ["tenant-a-*", "shared-catalog"]
  }
]

Without collections a key may use every collection, as before; ["*"] means the same but still counts as scoped for the server-wide endpoints below. A scoped key keeps its role, and:

  • Collections and aliases. Every request that names a collection must name one in scope, or answers 403 forbidden (PERMISSION_DENIED). An alias is judged by the collection it points to, so an admin can point tenant-a-live at tenant-a-v2 and the tenant can use it, but an alias into another tenant's collection is refused even if its own name matches. Names that do not exist are judged as they are, so a scoped key cannot find out whether a collection outside its scope exists.
  • New names. Creating a collection, renaming one, and restoring a snapshot under a new name need the new name in scope.
  • Alias changes. Setting or deleting an alias needs the alias's name, the collection it points to now, and the collection it is to point to, all in scope.
  • Listings. GET /collections, /aliases and /snapshots (and their gRPC calls) list only what is in scope; /info counts only those collections.
  • Snapshots are judged by the collection they were taken of. An uploaded archive of a collection out of scope is refused and removed.
  • /metrics names every collection, so it answers 403 to scoped keys. Give the metrics scraper an unscoped read-only key.

Scopes are checked in the same way over REST and gRPC (where SearchBatch checks each search's collection).

Sending a key

Over REST, in Authorization: Bearer <key> (the scheme is case-insensitive) or x-api-key: <key>; over gRPC, in the same names as metadata. The clients take an api_key and send it as a bearer token.

curl localhost:6900/collections
curl -H "Authorization: Bearer replace-with-another-secret" localhost:6900/collections
curl -X POST localhost:6900/collections -H 'x-api-key: replace-with-another-secret' \
  -H 'content-type: application/json' -d '{"name": "docs", "dimension": 3}'
curl -X POST localhost:6900/collections -H 'x-api-key: replace-with-a-long-random-secret' \
  -H 'content-type: application/json' -d '{"name": "docs", "dimension": 3}'
{"error":{"code":"unauthorized","message":"missing or invalid API key"}}
{"collections":[]}
{"error":{"code":"forbidden","message":"this API key is read-only"}}
{"name":"docs","dimension":3,"metric":"cosine","index":{"type":"hnsw","m":16,"ef_construction":128,"ef_search":64,"seed":6512614579346866177},"count":0,"tombstones":0}

No key or an unknown key answers 401 unauthorized (UNAUTHENTICATED over gRPC); a read-only key on a write answers 403 forbidden (PERMISSION_DENIED).

TLS

Give the server a PEM certificate chain and private key and both APIs speak TLS only, REST over HTTP/1.1 and HTTP/2 (negotiated with ALPN):

quiver --host 0.0.0.0 --api-key "$WRITE_KEY" --tls-cert cert.pem --tls-key key.pem
QUIVER_TLS_CERT=cert.pem QUIVER_TLS_KEY=key.pem quiver
curl --cacert ca.pem https://quiver.internal:6900/health

For mutual TLS, add --tls-client-ca ca.pem (QUIVER_TLS_CLIENT_CA): clients must then present a certificate one of its CAs signed, on both ports. API keys still apply on top. The certificates are read once at startup, and the server refuses to start if they do not parse or the key does not match; restart it to pick up renewed ones.

Without TLS, keys sent over the network can be read by anyone on the path, and the server warns at startup when it has keys, no TLS and a non-loopback address. A reverse proxy or load balancer that terminates TLS works too; it must speak HTTP/2 to the backend for gRPC.

The clients trust the public CAs by default. For a private CA or a client certificate:

ClientCA certificateClient certificate
PythonClient(url, ca_cert="ca.pem")client_cert="client.pem", and client_key="key.pem" if separate
RustClient::with_tls(url, &TlsOptions { ca_pem: Some(..), .. })identity_pem: the certificate chain followed by its key
Terminal UI--ca-cert / QUIVER_CA_CERT--client-cert / QUIVER_CLIENT_CERT, certificate then key

quiver --healthcheck with the same --tls-* settings speaks TLS to the server without verifying its certificate, since it only checks that the local server answers. With --tls-client-ca it has no client certificate to offer, so it only checks that the port accepts connections.

Deploying safely

  • Set keys before exposing the ports. The Docker image binds 0.0.0.0; set QUIVER_API_KEY or a keys file there.
  • Protect the keys file and the data. Keys are stored in plain text, so restrict the file's permissions. The data and snapshot directories hold every vector and all metadata.
  • Leave --cors off unless a browser application on another origin needs direct access.
  • Size limits. JSON bodies are capped at 64 MiB; lower it with --max-body-mb if your workload allows. Snapshot uploads are capped at 16 GiB and need a read-write key. k, batch sizes and id lists have limits too.
  • Uploaded snapshots are untrusted. Their manifest is checked first, their data is streamed through SHA-256 against it, and snapshot ids must be UUIDs, so they cannot name paths. A restore reads the archive in memory rather than extracting it to disk.

Reporting a vulnerability

Please don't open a public issue. Report it privately through GitHub Security Advisories, with what you found, how to reproduce it, the version (quiver --version or the commit) and the impact you expect. Quiver is pre-1.0: only the latest release and main receive security fixes. The full policy is in SECURITY.md.