Skip to main content
A credential is a stored secret — a label, an optional username, a password, free-form notes, and arbitrary string metadata. It is sealed at rest, shared through grants rather than copied, and audited on every read. Credentials are typically passwords, for a person to read. The other half of Custos is secret sets — bundles of environment variables, which come in two kinds: public sets a team shares and reads (dev secrets), and private sets delivered sealed to a host and read only by the machine. So the split is less human-versus-machine than password-versus-env-var: a public set is still read by people.

Create one

Creating needs the global credential.add permission. label is the only required field. Metadata is an arbitrary string map for things worth keeping next to the secret — a console URL, an account number, a ticket reference. Tags are for filtering: GET /credentials?tag=production.
Metadata is list-visible. GET /credentials returns it to anyone who can see the credential, with no reveal and no audit record. Secrets belong in password or notes, which are sealed and only come back from the reveal endpoint. A password put in custom metadata is readable by everyone who can see the credential exists.

Read one

Listing and fetching never include the secret. GET /credentials returns a page of metadata; GET /credentials/{id} returns one. To get the actual value:
Reveal is the only endpoint that decrypts, and it writes an audit record every time. That split is deliberate: browsing a vault is not the same event as reading a secret out of it, and only the second one matters for an audit trail.

Sharing by grant

Credentials are not copied between people. An admin grants a user credential.read on the credential — or on a group containing it — and the user reads it directly:
Revoking the grant ends access immediately. The four scoped permissions are credential.read, credential.update, credential.delete, and the global credential.add. Full model in Permissions.

Gated credentials

Set requires_permission on a credential and credential.read alone stops being enough. The holder must also have an approved, unexpired permission request:
ttl_seconds runs from 60 seconds to 7 days. When it expires, access lapses on its own — nobody has to remember to revoke it.
A request never creates authority. The requester must already reach the credential through a direct or group credential.read grant; the gate is a second factor on top, not a way in. Admins cannot file requests, because they already have access.
Only one request can be pending per person per credential — a second returns 409, as does asking for a credential that is not gated or that you can already reach. After a rejection or an expiry, the member may ask again. Every credential you can see reports your own standing in access_status:

Who has seen it

Anyone who may open a credential may see who else has. Filter with from, to, user_id, action, and q; page with limit and cursor. Admins get a second view answering a different question — not who did read it, but who could:
Each entry names the path: a direct grant, a resource group, or admin role. A person reachable two ways appears twice. Admins are listed explicitly — they bypass grants, so leaving them out would make the audit lie about who has access.

Deleting

DELETE /credentials/{id} needs credential.delete. The audit trail outlives the credential: the delete is recorded with a null credential id and the name denormalized onto the row, so history stays readable after the secret is gone.

How it is stored

Each credential’s password and notes are sealed as a single encrypted JSON blob under a per-secret AES-256-GCM data key, which is itself wrapped by the master key behind the vault’s key-wrapper interface — CUSTOS_MASTER_KEY in a simple deployment, a KMS or HSM in production.
This is not zero-knowledge. The control plane can decrypt, because it has to in order to return a value to an authorized caller. The threat model is a stolen database, not a malicious operator. If CUSTOS_MASTER_KEY is lost, every credential is unrecoverable.
If the key wrapper is not configured, every endpoint that encrypts or decrypts returns 503 rather than silently storing plaintext.