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:
| Endpoint | Limit |
|---|---|
| user login | 10 attempts per 15 minutes |
identity login (/machine/login) | 30 per 15 minutes |
enrollment token exchange (/machine/enroll) | 5 per 15 minutes |
| registration and confirmation emails | 3 per hour |
| token refresh | 60 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, andReferrer-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.