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 Basic Authorization 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 surrounding user: 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-snapshots refuses to write a value that holds a secretRef secret, because a snapshot is committed to the repository and a scrubbed copy would never match the next run. The assertion errors with snapshot 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.

verodocs