Guides
Secrets
A secret in vero is a value vero knows is one. It comes from the process environment through
secretRef, or from a response through a result marked secret, and from then on vero scrubs it
by value from every output it writes. Scrubbing by value, not by field, is what catches a token
that comes back inside a response body nobody expected to hold it.
Where a secret comes from
Three places in a plan make a value secret:
env:
apiKey: { secretRef: ORDERS_API_KEY } # read from the environment at load
connections:
catalog: { driver: postgres, dsn: { secretRef: CATALOG_RO_DSN } }
tasks:
- name: login
steps:
- http: { method: POST, url: "{{ env.baseUrl }}/auth/token", json: { key: "{{ env.apiKey }}" } }
assert: [status == 200]
results:
token: { secret: body.token } # a result the server produced
secretRef: NAME reads NAME from the environment when the plan loads. It takes a bare name:
no ${VAR} expansion and no {{ }} template inside it. An unset variable is a load error that
names the reference and never a value:
plan.yaml:10: env.apiKey: secretRef ORDERS_API_KEY is not set in the environment
plan.yaml: 1 load error(s); nothing ran
${VAR} and ${VAR:-default} also read the environment at load, in env values and connection
DSNs (Templates), but a value read that way is not a secret and is
never scrubbed. Use ${VAR} for a base URL, a tenant name or a port. Use secretRef for
anything whose appearance in a log would be a leak.
The four-byte minimum
A secret is scrubbed wherever its bytes appear, so a two-byte secret would redact half of any output it touched. vero refuses a secret shorter than 4 bytes. At load:
plan.yaml:10: env.apiKey: secretRef ORDERS_API_KEY resolves to a value of 3 bytes; a secret shorter than 4 bytes is refused, because scrubbing it by value would redact unrelated output
At run time, a result marked secret whose value is too short ends its step errored, the step's
exchange is shown nowhere, and no request event reaches the log:
ERROR short › step 1 plan.yaml
secret result code is 3 bytes; vero scrubs secrets of at least 4 bytes, so it stopped the step and does not show its exchange
0 passed 0 failed 1 errored 0 timed out 0 skipped 0 blocked 0 not run (1 tasks, 0 requests, 0.0s)
What scrubbing replaces
Every output passes through one writer that replaces each known secret with «redacted NAME»,
where NAME is the environment variable for a secretRef and tasks.<task>.results.<name> for
a result. It replaces the value in every form it commonly leaks in:
- the value itself;
- URL-encoded, as a query value and as a path segment;
- JSON-escaped once, and twice, since a JSON body inside a JSON event line is escaped twice;
- base64, as in a
BasicAuthorization header, at every byte alignment and in both the standard and the URL alphabets. The replacement covers the run of characters that depends on the secret alone, so the surroundinguser:part stays.
Longer forms are replaced first, so an escaped form goes whole rather than around its core. The writer holds output until a newline, so a value split across two writes is still seen whole.
A failing login shows all of this at once. The key went out in the request body and the token came back in the response, and both are gone from the failure block:
FAIL login › step 1 › assert #1 plan.yaml:28
POST http://127.0.0.1:18080/auth/token HTTP/1.1 200 OK 1.4ms
> accept-encoding: gzip
> content-type: application/json
> user-agent: vero
> {"key":"«redacted ORDERS_API_KEY»"}
< content-length: 41
< content-type: application/json
< date: Sat, 03 Oct 2026 02:30:33 GMT
< {"token":"«redacted tasks.login.results.token»"}
assert status == 201 failed
status = 200
plan.yaml:28
A later task that sends the token as a header, against an endpoint that echoes its headers back:
> authorization: Bearer «redacted tasks.login.results.token»
> user-agent: vero
> x-api-key: «redacted ORDERS_API_KEY»
< {"contentLength":0,"headers":{"accept-encoding":"gzip","authorization":"Bearer «redacted tasks.login.results.token»","user-agent":"vero","x-api-key":"«redacted ORDERS_API_KEY»"},"method":"GET","transferEncoding":null}
And a key passed as user info in a URL, which the client turns into a Basic header the server
echoes base64-encoded:
GET http://tester:«redacted ORDERS_API_KEY»@127.0.0.1:18080/fixtures/echo HTTP/1.1 200 OK 0.7ms
< {"contentLength":0,"headers":{"accept-encoding":"gzip","authorization":"Basic dGVzdGVyOm«redacted ORDERS_API_KEY»Q=","user-agent":"vero"},"method":"GET","transferEncoding":null}
The dGVzdGVyOm before the marker encodes tester:; the Q= after it is padding. Neither
carries a bit of the key.
Where it applies
Once the plan has loaded, every byte vero writes goes through the scrubber: the terminal report
on stdout and stderr, every line of the event log (vero.events/v1),
the JUnit and TAP files, the HTML report, and the flake report's observed values after
--repeat. A script step's stdout events and stderr lines pass through it too,
so a script that logs the token it was given leaks nothing into vero's output.
Where it does not apply
Scrubbing is of vero's output, and nothing else. Said plainly:
- The wire. Templates render the real value, and the server receives it. That is the point.
- Files a script writes. A screenshot, a trace or a video a browser script saves has whatever the page showed. The Playwright guide says so where it matters.
- Snapshot files.
--update-snapshotsrefuses to write a value that holds asecretRefsecret, because a snapshot is committed to the repository and a scrubbed copy would never match the next run. The assertion errors withsnapshot snapshots/echo.json not written: the value holds the secret ORDERS_API_KEY, and no file is created. - The two signal messages.
vero: interrupt: stopping; ...goes to the raw stderr, before the scrubber. It carries no values. - Anything printed before the plan loaded. Load errors and usage errors come first. They name references, paths and byte counts, never a value.
Results marked secret
A result written { secret: <expression> } is evaluated as soon as its step's response is in,
before any event of that step is written, and whether the assertions hold or not. A token in a
failing step's output is still a token. From that moment the value is scrubbed under the name
tasks.<task>.results.<name>; the template that sends it to the server still renders the real
value. vero plan --explain marks it:
steps
1. http POST {{ env.baseUrl }}/auth/token: 1 request
results: token = body.token (secret: scrubbed from every output once the step answers)
examples/orders.yaml reads its API key through secretRef and keeps
the login token as a plain result, since the fixture's token is disposable.
examples/playwright.yaml marks it secret, because the page it drives logs the token to the
browser console and the script forwards console lines as log events:
results:
token: { secret: body.token } # scrubbed from every output, the page's console included
A script step's result cannot be secret. Its result event streams into the log as the child writes it, before vero could know which value to scrub, so the loader refuses it:
plan.yaml:11: tasks.s.steps[0].results.t.secret: a script step's result cannot be secret: its result event is written as it arrives, before vero could scrub it; keep the secret out of the script's output
Keep the secret out of the script's output instead: pass it in through input and have the
script return something derived, or nothing.
Certificates and keys
A private CA, a client certificate and its key belong to the environment a run targets, not to
the plan, so they are flags of vero run and never plan keys: --ca-cert, --client-cert and
--client-key (command line). A key that group or others can read draws a
warning on stderr before the run starts, the way ssh does:
vero run: warning: --client-key k.pem is readable by group or others (mode 0644); chmod 600 it
In CI, the environment is where secrets arrive: the job exports ORDERS_API_KEY from its secret
store and the plan reads it through secretRef, so the plan file commits nothing.
Running in CI has the shape of such a job.
This page is docs/content/guides/secrets.md in the repository.