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

# Secrets

> Versioned application secrets, encrypted at rest, with a recovery window and optional binding to a KMS key you own.

Secrets stores small sensitive values — database passwords, third-party API
tokens, signing material — and hands them back only to a caller that is allowed
to read them. Values are never stored in the clear: every version is encrypted
before it reaches the database, and only ciphertext is persisted.

The service is **regional** — `secrets.sa-saopaulo-1.basaltic.sh`.

<CardGroup cols={2}>
  <Card title="Metadata and value" icon="split" href="#the-value-is-a-separate-endpoint">
    Two endpoints, two IAM actions, and why reading the value is the audited
    one.
  </Card>

  <Card title="Versions" icon="layers" href="#versions">
    Rotation without losing what came before.
  </Card>

  <Card title="Delete and restore" icon="undo-2" href="#deleting-and-restoring">
    The recovery window, and the one thing repeating a delete will not do.
  </Card>

  <Card title="Your own KMS key" icon="key-round" href="#encrypting-under-your-own-key">
    What binding a secret to a customer-managed key actually changes.
  </Card>
</CardGroup>

## Creating a secret

<Tabs>
  <Tab title="Console">
    Go to **Secrets** and choose **Create Secret**. Under **Secret details**
    give it a **Name** and an optional **Description**, paste the first
    **Value**, and leave **Encryption key** on **Platform-managed key
    (default)** unless you want one of your own keys.

    The console takes the value as text and base64-encodes it for you; over
    the API you encode it yourself.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    POST https://secrets.sa-saopaulo-1.basaltic.sh/v1/secrets
    {
      "name": "prod/api/stripe-key",
      "value": "c3VwZXItc2VjcmV0LXZhbHVl",
      "description": "Stripe live secret key for the payments service"
    }
    ```
  </Tab>
</Tabs>

A secret is created **with** its first value — there is no such thing as an
empty secret. The response is `201` and the secret is at version 1.

<ResponseField name="value" type="base64, 1 byte – 64 KiB">
  Values travel base64-encoded so arbitrary binary payloads survive JSON
  intact. Anything larger than 64 KiB belongs in a [bucket](/storage), with
  the secret holding the reference and the credentials to fetch it — not
  crammed inline.
</ResponseField>

<ResponseField name="name" type="unique per account">
  Matches `^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,255}$`. Slashes are allowed, which is
  why `prod/api/stripe-key` works — and because the CRN is built from the name,
  a path convention becomes directly policy-able:
  `crn:secrets:sa-saopaulo-1:my-account:secret/prod/*`.
</ResponseField>

## The value is a separate endpoint

Describing a secret and reading it are different operations with different
permissions.

<Columns cols={2}>
  <Card title="GET /v1/secrets/{id}" icon="file-text">
    Name, description, tags, current version number, which KMS key it is bound
    to, deletion state. **Never the value.** Guarded by
    `secrets:DescribeSecret`.
  </Card>

  <Card title="GET /v1/secrets/{id}/value" icon="eye">
    The decrypted plaintext, base64-encoded. Guarded by the separate,
    narrower `secrets:GetSecretValue`.
  </Card>
</Columns>

This split is the point of the service. A deployment tool, a dashboard or an
inventory job can be given `secrets:DescribeSecret` across everything and still
be unable to read a single value.

<Note>
  **Every plaintext read is recorded, successful or not.** A denied read, a
  probe for an id the caller does not own, and a decrypt that failed are all
  written to the audit trail with the error code that stopped them — that set
  is exactly what a review is looking for, and it would be invisible if only
  successes were logged.
</Note>

Add `?version=N` to read a specific version instead of the current one.

<Note>
  The console's **Reveal** button, on each row of a secret's **Versions** tab,
  calls this endpoint. It is the same operation with the same permission and
  the same audit record — opening a secret in a browser is not a quieter way to
  read it.
</Note>

## Versions

<Tabs>
  <Tab title="Console">
    Open the secret from **Secrets** and choose **Put New Value**. The **Put
    new value** dialog takes the **New value**; **Store new version** writes
    it and it becomes current.

    Every version stays listed on the **Versions** tab, with the newest marked
    **Current**.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    POST /v1/secrets/{secret_id}/value
    { "value": "bmV3LXNlY3JldC12YWx1ZQ==" }
    ```
  </Tab>
</Tabs>

Each call allocates the next version number and makes it current; the previous
version stops being current but stays readable by explicit `?version=`. That is
what makes rotation safe — you publish the new value, let consumers pick it up,
and still have the old one to fall back to.

`GET /v1/secrets/{secret_id}/versions` lists version metadata, highest first.
Each entry carries `created_by`, the CRN of the principal that wrote it
(`crn:iam:::user/<id>` or `crn:iam:::service-account/<id>`), so you can see who
rotated what.

<Note>
  Version metadata never includes ciphertext. `GET .../value` is the only route
  that produces plaintext, and it is the only one that needs
  `secrets:GetSecretValue`.
</Note>

## Deleting and restoring

Deleting a secret removes nothing immediately. It moves the secret into a
**recovery window** and returns `deleted_at` and `scheduled_purge_at`. Once
`scheduled_purge_at` passes, the secret and every version are removed for good.

<Tabs>
  <Tab title="Console">
    Open the secret from **Secrets** and use **Delete secret**, in the **Danger
    zone** on its **Settings** tab. The dialog takes a **Recovery window
    (days)** and will not confirm until you type the secret's name back.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    DELETE /v1/secrets/{secret_id}
    { "recovery_window_seconds": 1209600 }
    ```
  </Tab>
</Tabs>

<ResponseField name="recovery_window_seconds" type="86400 – 2592000, default 604800">
  1 to 30 days, defaulting to 7. Set it at creation to give a secret its own
  default, or pass it on the delete call to override it for that deletion.
</ResponseField>

While a secret sits in the window:

* `GET /v1/secrets/{id}` still describes it, so you can see the purge date.
* Reading or writing the value is refused with `409 SECRET_DELETED`.
* Updating metadata is refused the same way.
* It is hidden from `GET /v1/secrets` unless you pass `include_deleted=true`.

Restoring cancels the purge and puts the secret back. Restoring one that was
never deleted is a harmless no-op.

<Tabs>
  <Tab title="Console">
    A secret in the window shows **Restore** in its header, in place of **Put
    New Value**.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    POST /v1/secrets/{secret_id}/restore
    ```
  </Tab>
</Tabs>

<Warning>
  Repeating the delete **does not extend the window**. A second `DELETE` during
  the window keeps the original `scheduled_purge_at` rather than pushing it
  out, so a retry loop or a re-run of a teardown script cannot quietly keep a
  secret alive forever. If you need more time, restore the secret and delete it
  again with a longer `recovery_window_seconds`.
</Warning>

<Note>
  Deleting frees the secret's quota straight away, so you can create a
  replacement without waiting out the window. Restoring has to take that quota
  back, which means a restore can fail with `403 QUOTA_EXCEEDED` if your
  account has since filled the slot. Free one before restoring.
</Note>

## Encrypting under your own key

By default a secret's versions are encrypted under a platform-managed key. Bind
the secret to one of your own [KMS](/kms) keys at creation to use that instead:

<Tabs>
  <Tab title="Console">
    On **Create Secret**, open the **Encryption** card and choose a key under
    **Encryption key** rather than **Platform-managed key (default)**.

    The picker offers every key in the region that is `enabled` and pinned to
    **Encrypt / Decrypt**, which includes RSA keys that this service will not
    accept — see the warning below.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    POST /v1/secrets
    {
      "name": "prod/api/stripe-key",
      "value": "c3VwZXItc2VjcmV0LXZhbHVl",
      "kms_key_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
    }
    ```
  </Tab>
</Tabs>

The key must be one of yours, in the same region, `enabled`, and pinned to
`encrypt_decrypt`.

<Warning>
  It must also be **symmetric — `aes-256`**. An RSA key with
  `encrypt_decrypt` usage is refused with `400 KMS_INVALID_KEY_SPEC`, because
  every version is sealed with the secret's identity bound in as encryption
  context and RSA-OAEP has nowhere to carry one. Accepting the key would drop
  that binding silently, so it is refused at creation instead.
</Warning>

### What it changes

Binding a secret to your key does not change the API surface — `value` reads
and writes look identical. What changes is that **the key becomes a control you
hold**:

* Disable the key and reads start failing with `409 KMS_KEY_DISABLED`. That
  error travels to you as itself, not as a `500` — the key state is yours, and
  re-enabling the key restores reads.
* Schedule the key for deletion and reads fail with
  `409 KMS_KEY_PENDING_DELETION` for the whole window.
* Let that window elapse and **every version of every secret under that key is
  permanently unreadable.** The ciphertext is still in the database; nothing
  can open it.

<Note>
  The binding is fixed for the life of the secret. `kms_key_id` is accepted
  only on create, and `PATCH` edits just `description` and `tags` — moving a
  secret to a different key means re-wrapping every version, so create a new
  secret and retire the old one instead.
</Note>

### Versions are bound to their secret

Whichever key is used, the ciphertext of every version is sealed with the
secret's account and secret id as encryption context. A stored blob therefore
only decrypts as the version of the secret it was written for — it cannot be
lifted out of the database and replayed as the value of a different secret,
even one encrypted under the same key.

## Secrets the platform owns

Some secrets are created *by* a service that then reads the value back and acts
on it — a managed database's generated user password, for instance. Those come
back with `managed: true`.

You can read them and you can delete them. `PUT`-style writes are refused with
`403 SECRET_PLATFORM_MANAGED`:

* **`POST .../value`** — overwriting the value would not change whatever the
  value describes, it would make the two disagree, turning a credential the
  platform trusts into customer-supplied bytes.
* **`PATCH /v1/secrets/{id}`** — including tags, because tags feed the
  conditions IAM policies are written against. Retagging a secret the platform
  reads back would let a caller move it in and out of the scope of their own
  policies.

The flag is on the wire so a client can explain the refusal before making the
call rather than after.

## Controlling access

Every operation checks a distinct IAM action. Operations on a specific secret
are authorized against **that secret's CRN**, with the secret's tags available
as condition context.

| Action                   | Scope      | Guards                       |
| ------------------------ | ---------- | ---------------------------- |
| `secrets:ListSecrets`    | collection | Listing secrets              |
| `secrets:CreateSecret`   | collection | Creating one                 |
| `secrets:DescribeSecret` | secret CRN | Metadata                     |
| `secrets:UpdateSecret`   | secret CRN | Description and tags         |
| `secrets:DeleteSecret`   | secret CRN | Entering the recovery window |
| `secrets:RestoreSecret`  | secret CRN | Leaving it                   |
| `secrets:GetSecretValue` | secret CRN | **Reading the plaintext**    |
| `secrets:PutSecretValue` | secret CRN | Writing a new version        |
| `secrets:ListVersions`   | secret CRN | Version metadata             |

<Tip>
  `secrets:GetSecretValue` is deliberately a different action from
  `secrets:DescribeSecret`, so the operation that returns sensitive material
  can be granted on its own, to the few principals that need it, on the few
  secrets they need. The same pattern shows up on
  [KMS](/kms#controlling-who-may-use-a-key) with `kms:Decrypt`, and on
  [certificates](/certificates#certificate-material) with
  `certificate:GetCertificateMaterial`.
</Tip>

One service reading exactly the secrets under its own path prefix:

```json theme={null}
{
  "version": "2024-01-01",
  "statements": [{
    "sid": "PaymentsReadsItsOwnSecrets",
    "effect": "allow",
    "actions": ["secrets:DescribeSecret", "secrets:GetSecretValue"],
    "resources": ["crn:secrets:sa-saopaulo-1:my-account:secret/prod/payments/*"]
  }]
}
```

A rotation job that may write but never read:

```json theme={null}
{
  "sid": "RotateOnly",
  "effect": "allow",
  "actions": ["secrets:PutSecretValue", "secrets:ListVersions"],
  "resources": ["crn:secrets:sa-saopaulo-1:my-account:secret/prod/*"]
}
```

<Note>
  `secrets:ListSecrets` is authorized against the collection rather than
  against individual secrets, so restricting it by CRN or tag has no effect.
  Scope `secrets:GetSecretValue` — that is the grant that matters.
</Note>

See [writing policies](/iam/policies) for the document format, the tag
condition keys, and how a `deny` guardrail survives a broad allow.

## Errors

| Code                             | Status | Means                                                                         |
| -------------------------------- | ------ | ----------------------------------------------------------------------------- |
| `SECRET_NOT_FOUND`               | 404    | No secret with that id **in your account**.                                   |
| `SECRET_VERSION_NOT_FOUND`       | 404    | `?version=` names a version that does not exist.                              |
| `SECRET_NAME_EXISTS`             | 409    | Another secret of yours already holds the name.                               |
| `SECRET_DELETED`                 | 409    | Reading or writing a secret that is in the recovery window. Restore it first. |
| `SECRET_PLATFORM_MANAGED`        | 403    | Writing the value or metadata of a `managed: true` secret.                    |
| `SECRET_VALUE_TOO_LARGE`         | 400    | Value over 65536 bytes.                                                       |
| `SECRET_INVALID_RECOVERY_WINDOW` | 400    | `recovery_window_seconds` outside 86400–2592000.                              |
| `KMS_KEY_NOT_FOUND`              | 404    | `kms_key_id` names no key of yours.                                           |
| `KMS_KEY_DISABLED`               | 409    | The bound key is disabled — enable it and reads resume.                       |
| `KMS_KEY_PENDING_DELETION`       | 409    | The bound key is inside its deletion window.                                  |
| `KMS_INVALID_KEY_SPEC`           | 400    | The bound key cannot bind an encryption context. Use an `aes-256` key.        |

## Next

<CardGroup cols={2}>
  <Card title="KMS" icon="key-round" href="/kms">
    Creating the key a secret can be bound to, and what disabling it does.
  </Card>

  <Card title="Writing policies" icon="file-text" href="/iam/policies">
    Scoping `secrets:GetSecretValue` to a path prefix or a tag.
  </Card>

  <Card title="API reference" icon="code" href="/api-reference/introduction">
    Every secrets operation, with request and response schemas.
  </Card>

  <Card title="Authentication" icon="key" href="/authentication">
    Signing requests to a regional endpoint.
  </Card>
</CardGroup>
