> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tofunmiadewuyi.com/custos/llms.txt
> Use this file to discover all available pages before exploring further.

# Credentials

> Store a secret once, share it by grant, and see every read.

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](/custos/custos/guides/machine-secrets) — 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

```bash theme={null}
curl -sS -X POST "$CUSTOS_URL/credentials" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "label": "prod-db",
        "username": "root",
        "password": "...",
        "notes": "rotate quarterly",
        "tags": ["production", "database"],
        "metadata": {"host": "db-01"}
      }'
```

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`.

<Warning>
  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.
</Warning>

## 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:

```bash theme={null}
curl -sS "$CUSTOS_URL/credentials/$ID/reveal" -H "Authorization: Bearer $TOKEN"
```

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:

```bash theme={null}
curl -sS -X POST "$CUSTOS_URL/grants" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"user_id":"...","permission":"credential.read","target_kind":"credential","target_id":"..."}'
```

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](/custos/custos/concepts/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**:

```bash theme={null}
# member asks
curl -sS -X POST "$CUSTOS_URL/credentials/$ID/permission-requests" -H "Authorization: Bearer $TOKEN"

# admin approves, time-boxed
curl -sS -X PATCH "$CUSTOS_URL/permission-requests/$REQ_ID" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"approval_status":"approved","ttl_seconds":3600}'
```

`ttl_seconds` runs from 60 seconds to 7 days. When it expires, access lapses on its own — nobody has
to remember to revoke it.

<Note>
  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.
</Note>

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`:

| Value | Meaning |
| - | - |
| `not_required` | ungated; `credential.read` is sufficient |
| `approved` | you may reveal it now — `access_expires_at` says until when |
| `pending` | you have asked and nobody has reviewed it yet |
| `requestable` | you hold the grant but need to ask |

## Who has seen it

```bash theme={null}
curl -sS "$CUSTOS_URL/credentials/$ID/audit" -H "Authorization: Bearer $TOKEN"
```

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*:

```bash theme={null}
curl -sS "$CUSTOS_URL/credentials/$ID/access-audit" -H "Authorization: Bearer $ADMIN_TOKEN"
```

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.

<Warning>
  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.
</Warning>

If the key wrapper is not configured, every endpoint that encrypts or decrypts returns `503` rather
than silently storing plaintext.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.