Skip to main content

Identity and authentication

Two different questions, and conflating them causes real bugs:

  • Access is the API key's job: prove you hold a credential, and get read-write or read-only in return.
  • Identity is who the client is: the client half of the (aggregate, client) key that makes retries idempotent. It grants nothing; it names you.

An API key carries no identity. A client id carries no permissions. Both are separate from mTLS, which secures the transport but does not say who is on the other end.

Both ride one optional message, the Identify frame at the start of a connection. What the server enforces depends on whether that frame arrives and what it holds; read what is actually enforced before relying on either.

Access: API keys

Keys live as SHA-256 hashes in api_keys.toml under --data-root, four slots: primary_rw, secondary_rw, primary_ro, secondary_ro. celeriant keys generate --data-root <dir> writes all four and prints the keys once. A key is 32 random bytes, sent base64 in the Identify frame.

With the file present, an Identify without a key fails with AuthRequired (10005), a wrong or malformed key with AuthInvalidKey (10006), and a write-class request (write, trim, delete, schema registration) on a read-only key with AuthInsufficientPermissions (10007). With no file, the server checks nothing and every connection is read-write.

Two slots per level make rotation boring. Issue the secondary, move clients to it, then celeriant keys regenerate primary-rw --data-root <dir>. Idempotency is untouched, because dedup is keyed by client id, not credential. The server rereads the file only when --tls-cert-reload-interval-secs is non-zero (it shares that timer; default 0, off), and then only new connections see the change. Otherwise restart. The regenerate command tells you to send SIGHUP; the server has no SIGHUP handler.

Identity: where your client id comes from

Every write carries an explicit 128-bit clientId. Two ways to back it.

Choose it yourself. A UUID, or a UUIDv5 derived from a stable name. On a connection with no verified identity the server takes the field at face value. Fine for trusted backend writers; be clear that any client can claim any id.

Prove it with a keypair. The client holds an RSA key and puts three things in Identify: its public key (SPKI DER, base64), a nonce that is the current Unix time in milliseconds, and an RSASSA-PKCS1-v1_5 SHA-256 signature over the nonce. The server accepts a nonce up to 2 minutes old and 60 seconds ahead; outside that, IdentifyInvalidNonce (10001). A bad signature is IdentifyInvalidSignature (10002). The client id is the first 16 bytes of SHA-256 over the DER key, so the same key always maps to the same id and no registration step exists. Rust's Crypto::generate_short_client_identity and .NET's CeleriantCrypto.GenerateClientIdentity compute it client-side.

Once a connection is verified, every write, trim, delete and schema registration on it must carry that id, or it fails with IdentifyMismatch (10003).

The nonce is client-generated and the server keeps no replay cache, so a captured Identify replays for up to 2 minutes. That is why the server refuses to start with API keys or --require-client-identity unless TLS is on; --insecure-allow-plaintext-auth overrides it, for development only.

What is actually enforced

Keys are checked, and identity is verified, only inside an Identify frame. A client that skips Identify skips both, and its connection is read-write with no verified id. --require-client-identity exists to close that door, and it does exactly what its help text says: the first frame must be an Identify, or the connection gets IdentifyRequired (10004) and is closed. It does not require the Identify to contain anything.

Server setupA client that sends no IdentifyAn empty IdentifyVerified client id
no keys, no flagread-writeread-writeonly if the client sends a keypair
keys, no flagread-write, key never checked10005only if the client sends a keypair
flag, no keys10004read-writeonly if the client sends a keypair
keys and flag1000410005only if the client sends a keypair

Two consequences:

  1. API keys without --require-client-identity do not keep anyone out. Run them together.
  2. No server setting forces a keypair. If you need verified ids, make your clients send one, and check the ClientId the Identify response echoes back.

The clients differ in what they send:

  • Rust. The pool sends Identify only when with_identity is set. Without one it never identifies, so it also never receives the compression dictionary and sends uncompressed. Against --require-client-identity it gets 10004. Given a key and a keypair, it sends both.
  • .NET. The pool sends Identify only when IdentityConfig is set, so an unconfigured .NET client also gets 10004 under the flag. With both a key and a keypair configured it sends the key and drops the keypair.

So against a keyed cluster, a .NET client never gets a verified client id.

Keep your client id stable

This is the part that bites, and the failure is silent. Idempotency tracks the highest ClientSeq per (aggregate, client). Change a writer's identity (a regenerated keypair, a fresh GUID per process) and the server sees a new client, so a retried write lands as a brand-new event. No error. You find it later when the projection numbers do not add up.

So a long-lived writer keeps the same client id, and the same keypair if it uses one, across restarts. Persist them like a database password. The client libraries never pick an id for you; every write asks for it.

Stable also means shared. A horizontally scaled service is one writer with one client id, not one per pod and never one per request. Replicas sharing an id is the supported shape: optimistic concurrency serialises them, and the idempotency guide covers the one check it needs. A fresh id per pod or per request makes the server walk the aggregate's history for every new (aggregate, client) pair and erodes the dedup the id exists for. If you verify identity, the fleet shares one keypair, mounted as a secret.

Rotating an API key never creates a duplicate. Regenerating a keypair is a new identity: drain the old writer (no retries outstanding) before you switch.

Pre-release

Celeriant is at 0.2.0, pre-1.0. The Identify handshake can still change.