env-master
Documentation sections

Security

What the system guarantees, what it doesn't, and why.

Where the boundary is

At rest — only ciphertext. Values are encrypted with XChaCha20-Poly1305 under the environment's data key, and the key itself is stored encrypted under the root key, which lives in the operator's environment or in an external KMS. A stolen MongoDB dump without the root key is useless.

In operation — depends on the path:

  • the machine path is honestly client-side: the server returns ciphertext to the SDK plus the data key wrapped with x25519 under the specific identity's key. In this scenario the server never produces the plaintext value at all;
  • writing and revealing a value to a human go through the server as plaintext over TLS.

That is why we don't call the system zero-knowledge. A live process has access to values while processing them, and promising otherwise would be untrue — that is exactly the attempt Infisical stumbled on when its dashboard and integrations needed plaintext.

Identity authentication

Nothing is permanent: the client secret has revocation, the access token has a TTL in minutes, the enrollment token has ten minutes and exactly one use.

A repeated enrollment token exchange means it leaked. The system does not just refuse: token capture is atomic (compare-and-set), and the losing attempt raises an enrollment.reuse-detected event and a webhook to the administrators. The mechanism and the response steps are covered on the Services and identities page.

Audit

Two client-visible streams: changes (activity) and accesses (access). Every read of a secret — by a human or by an identity at startup — is recorded. Events are chained at the organization level, so an entry cannot be deleted or rewritten unnoticed.

A link is computed under a key derived from the audit key, which lives in the service's environment, not in the database. The difference is fundamental: a chain of plain hashes can be verified by anyone — which means it can be recomputed by anyone. Someone with write access to the database would delete events, rebuild the chain from the break, and rewrite its head, which sits in the same place. With a key this is impossible: the chain cannot be recomputed without it.

Verification is the audit:verify command. It walks from the first event link by link and checks each one, also catching what per-hash comparison alone would miss: a fork, a truncated tail, and events unreachable from the start of the chain. A property that cannot be verified is not a property.

The chain head is signed too — it cannot be rewritten by deleting the latest events without the key. One case remains that cannot be told apart from inside the database: a rollback to a state that was once genuine, restored from an old backup. Against it, the server writes an anchor — a snapshot of the head — to its own log once an hour, and that log leaves the host. Feed the accumulated anchors to the verification (audit:verify --anchors <file>), and it will confirm that every recorded link is still in its place — and that the log has not been wiped entirely. Without anchors the verification knows nothing about a rollback and says so plainly.

Granularity equals the anchor interval: a rollback to a state newer than the latest anchor is invisible by construction; the window is one hour. The anchor itself contains no head fingerprint — otherwise a line from the log would become a ready-made license to truncate the chain to that length.

Anchors accumulated before the version in which they got their own signature are invalid and are marked as unsigned. This is not a failure: accepting the old form would leave open exactly the door the signature was introduced to close. Full coverage starts with the next anchoring interval.

There are no values in the log: only key names and an HMAC of the value. That is enough to see "the value changed", and not enough to learn what it is.

Rate limits

Login and token exchange are limited — per IP and per subject; on refusal the server responds with 429 RATE_LIMITED and a Retry-After header:

EndpointLimit
user login10 attempts per 15 minutes
identity login (/machine/login)30 per 15 minutes
enrollment token exchange (/machine/enroll)5 per 15 minutes
registration and confirmation emails3 per hour
token refresh60 per 15 minutes

Perimeter

  • a webhook address is validated twice: when saved and before every delivery; internal and loopback addresses are rejected — otherwise the server would become a scanner of its own network;
  • request and value sizes are limited both in the application and at the reverse proxy;
  • the browser's refresh token lives in an httpOnly cookie, the access token — only in tab memory: XSS steals 15 minutes, not 30 days;
  • the API responds with Content-Security-Policy: default-src 'none', X-Content-Type-Options: nosniff, a frame ban, and Referrer-Policy: no-referrer; HSTS is the job of the reverse proxy that terminates TLS.

Root key rotation

The root key is not permanent either:

OLD_ROOT_KEY=... NEW_ROOT_KEY=... bun run db:rekey -- --dry-run

Only the environment keys and the webhook signing secrets are re-encrypted — values are untouched, so the operation costs on the order of the number of environments, not the number of secrets. The run is resumable and stops if any element cannot be opened by either key.