Skip to content

Store a secret through the API

A document never carries a credential. It names one, api_key: ${secret:openai}, and each node resolves the name from wherever the platform delivered it, in a fixed order:

  1. a file openai under the node’s secrets_dir (a mounted Kubernetes Secret, the operator’s Credential, External Secrets, a Vault agent);
  2. an environment variable openai (the chart’s envFrom);
  3. the cluster’s secret store, this page.

The first two need somebody who can put a value on a node. The store is for the person who has a console and cannot.

$TOKEN is a JWT from the issuer this gateway trusts, there is no admin secret to hold. pistra login caches one and pistra admin -url presents it for you. The curl form is here because it is what a script or an SDK sends. See Sign in from the command line.

Run this command:

Terminal window
$ curl -X PUT https://pistra-admin.example/admin/v1/secrets/openai \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"value":"sk-…"}'
{"secret":"openai","written_at":"2026-08-27T12:00:00Z","applied":41}

Or the console’s Secrets page. The name is letters, digits, dot, underscore and dash. The value is stored exactly as sent, untrimmed. Writing to a name that already exists replaces it, there is no precondition, because “set it to this” means the same thing whatever it held before.

If the response carries "shadowed_by": "file" or "env", the node that took the write already has that name delivered locally, and the local value is the one in effect there. The store never overrides a delivery. GET /admin/v1/secrets lists every name this node resolves and which layer answers it.

Add this configuration:

providers:
- preset: openai
api_key: ${secret:openai}

in any source, and in any of the fields that hold a credential: api_key, client_secret, access_key_id, secret_access_key, session_token, token, a reconcile provider’s admin_key, the guardrails fpe_key and pseudonym_key, and a credential header in a detector’s headers. The console’s credential fields offer the names this node resolves as suggestions. A reference to a name nothing delivers and nothing stores is refused at the source write, with the PUT that would fix it in the message.

Write the new value to the same name. Every node rebuilds, and every source that references the name serves the new value. No document changes and no revision moves, and the audit trail records who rotated what and when, never the value. Later delivering the same name as a file, moving to External Secrets, say, takes over on the next rebuild without touching the document either, because of the fixed order.

Run this command:

Terminal window
$ curl -X DELETE https://pistra-admin.example/admin/v1/secrets/openai -H "Authorization: Bearer $TOKEN"

A stored secret a source still references, and nothing else delivers, is refused with a 422 naming the reference. A source holding the credentials the providers name cannot be removed from under them for the same reason. Take the reference out first, or deliver the name another way.

The value is sealed by the node that takes the write, before it becomes a raft entry. The seal is AES-GCM under a key derived from the cluster secret every member holds, the same barrier the ACME certificate storage uses. It rests on every node’s disk as ciphertext and is opened on each node on the way into that node’s snapshot. No operation returns it.

The key arrangement is Vault’s. A data key seals values, and the data key is held in a replicated keyring wrapped under one or more key-encryption keys, by default the cluster secret’s, optionally a key file’s (cluster.kek_file). Rotating either is three small writes and never touches a sealed value. See Rotate the cluster secret.

It is not a secrets manager. There are no versions, no leases, no dynamic credentials, and no key that never rests on a node. A KMS KEK is the shape the keyring is built for and does not have yet. A deployment with Vault or External Secrets should keep delivering files. Its documents already say ${secret:NAME}, and the store never answers.

The store is its own resource class, secrets, with the secret’s name as the instance:

@id("platform-holds-the-keys")
permit(principal in Role::"platform", action, resource in Resource::"secrets");
@id("team-a-rotates-its-own")
permit(principal in Role::"team-a", action == Action::"putSecret", resource == Resource::"secrets/team-a-openai");

A grant on config does not carry it. Being allowed to write a team’s source is not being allowed to overwrite the platform’s provider keys.