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_KEYA 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.
Where a value is looked up
Section titled “Where a value is looked up”In order, most specific first:
- This shell’s environment. Somebody who typed an export meant it, and is usually debugging.
.env. A repository’s file, checked out with the branch.- The encrypted local store. A file under
.antifailure, for this repository. - 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.
One name, two services
Section titled “One name, two services”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: serviceThe 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.
Nothing found
Section titled “Nothing found”AF-SEC-001 The variables STRIPE_SECRET_KEY are declared in the manifest butwere 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.
The local store
Section titled “The local store”af secret set STRIPE_SECRET_KEY # reads the value without echoing itaf secret list # names only, never valuesaf secret rm STRIPE_SECRET_KEYThe 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 keyringanswered 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.
A credential that stopped working
Section titled “A credential that stopped working”AF-SEC-002 The credential for Azure Key Vault at https://af.vault.azure.netwas 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.
A live key where a sandbox key belongs
Section titled “A live key where a sandbox key belongs”AF-SEC-003 The value supplied for STRIPE_SECRET_KEY carries a live credentialprefix, and STRIPE_SECRET_KEY is configured for sandbox use.See sandbox credentials. Checked before anything starts.
Values never reach a log
Section titled “Values never reach a log”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.
More places to look
Section titled “More places to look”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.