Skip to content

Type to search pages.

View .md

Secrets

The manifest names variables. It never holds values.

database:
source_url_env: PRODUCTION_DATABASE_URL
egress:
rules:
- host: api.stripe.com
mode: sandbox
credential: STRIPE_SECRET_KEY

A manifest is committed. A secret is not. Naming the variable keeps the file readable, reviewable, and safe to check in, and means somebody reading the repository can see what credentials an environment needs without holding any of them.

In order, most specific first:

  1. This shell’s environment. Somebody who typed an export meant it, and is usually debugging.
  2. .env. A repository’s file, checked out with the branch.
  3. The encrypted local store. A file under .antifailure, for this repository.
  4. The system keyring. The long lived default on a workstation, shared across repositories: the macOS keychain, the freedesktop Secret Service on Linux, and the Credential Manager on Windows.

The first source that has the value wins. The order is the point: a temporary override beats a file, and a file beats a stored default, which is what makes “try it with a different key” a one line thing.

Two services can need different values for one variable name. Supabase’s storage and supavisor both read DATABASE_URL and each needs a different connection string. Both are credentials, so neither can be a literal in the manifest, and a lookup by name alone could only ever hand both services the same one.

scope: service says the value is this service’s own:

services:
- name: storage
env:
- name: DATABASE_URL
scope: service
- name: supavisor
env:
- name: DATABASE_URL
scope: service

The value is then looked up under the service’s name in capitals, two underscores, then the variable, so those two are stored as STORAGE__DATABASE_URL and SUPAVISOR__DATABASE_URL. Every source can hold a name of that shape, including the enterprise secret stores, and the order above is unchanged: the shell is still asked first, then .env, then the local store, then the keyring.

A scoped variable is looked up under the scoped name only, and does not fall back to the bare one, because a single bare value is what cannot be right for both services. af explain names the service beside each value it is one service’s own, and a value nothing supplies is reported under the scoped name, so the message says the name to set rather than the name the service reads.

A sandbox credential cannot be scoped. The proxy holds one value per credential for the whole environment and substitutes it into every request to that provider whichever service sent it, so there is no value it could use for two, and choosing one would hand a service another service’s key. Two services reading one sandbox credential from different places is refused with AF-SEC-007, which names the variable and the services.

AF-SEC-001 The variables STRIPE_SECRET_KEY are declared in the manifest but
were not found in any configured source.
Next: Add them to one of the searched sources: this shell's environment,
.env (not present), the encrypted local store (no passphrase is set).

The message lists every source and why each did not answer, including the ones that are not available. A message that only said “not found” leaves you guessing which of three places to put it, and a source that is absent for a reason is more useful to know about than one that was silently skipped.

Terminal window
af secret set STRIPE_SECRET_KEY # reads the value without echoing it
af secret list # names only, never values
af secret rm STRIPE_SECRET_KEY

The value is never taken as an argument. An argument is in the shell history, in the process list, and in the CI log of whatever ran it.

The file is encrypted with a key derived from a passphrase using Argon2id and sealed with AES-256-GCM. There is no command that prints a stored value: a store that can print its contents is one screenshot away from not being a store.

AF-SEC-004 The encrypted local store has no passphrase: no system keyring
answered and AF_SECRET_PASSPHRASE is not set.

Set AF_SECRET_PASSPHRASE, which is what CI does. On a workstation the passphrase can live in the system keyring instead, so it does not need to be exported in every shell: the macOS keychain, the freedesktop Secret Service on Linux, and the Credential Manager on Windows. A machine with no keyring, which is a Linux server without libsecret and most containers, has no other way to open the store, and the message says which sources it considered rather than pretending one was tried.

There is deliberately no default passphrase. A store encrypted with a passphrase everybody knows only looks encrypted.

AF-SEC-002 The credential for Azure Key Vault at https://af.vault.azure.net
was rejected after one refresh: Key Vault answered 403 Forbidden.
Next: Rotate the credential and store the new value where it reads it. A
rejection that survives a refresh is a credential that was revoked or was
never right, so retrying will not help.

One renewal, once per process, then reported. Every store that holds a token which expires gets that one renewal, which covers a long-running process holding a stale token. A second rejection is not an expiry, and retrying a revoked credential once per declared variable turns a configuration mistake into a rate limit on a store everybody else is also using.

This comes from the enterprise secret stores, which are the sources that authenticate. See enterprise secret stores.

AF-SEC-003 The value supplied for STRIPE_SECRET_KEY carries a live credential
prefix, and STRIPE_SECRET_KEY is configured for sandbox use.

See sandbox credentials. Checked before anything starts.

Every connection string, token, and key is registered with the redactor when it is resolved, and everything on its way to a log, an artifact, or a screenshot goes through it. Redaction happens at the writer rather than at each call site, because a call site somebody forgot is exactly how a secret ends up in a CI log.

The engine has five writers that can put an event somewhere a person later reads it: the local NDJSON log, the spool on disk, a span attribute, the bytes an OTLP collector receives, and the body of the request the control plane receives. Each redacts at its own writer, and each has a test that a connection string cannot reach it. The last of those is the only one that leaves the machine, so a self-hosted control plane stores events that have already been through the redactor of the engine that sent them.

You will see this in error messages: postgres://user:[redacted]@host/db. That is working.

An organisation that keeps its credentials in HashiCorp Vault, AWS Secrets Manager, Azure Key Vault or Google Secret Manager can add them to the end of this chain with the enterprise edition. They are asked after every local source, for the same reason the keyring is asked after .env. See enterprise secret stores.

Related: sandbox credentials, egress.