Skip to content

Secrets

A secret is a named value (a token, an API key, a password) stored per project and made available to the jobs that run in it. Secrets are encrypted at rest and masked in log output.

Like this, a deploy job that reads one:

Terminal window
printf %s "$KEY" | spool secret set STRIPE_KEY
jobs:
deploy:
steps:
- name: push
run: |
curl -H "Authorization: Bearer $STRIPE_KEY" https://api.example.com/charges
Terminal window
spool secret set STRIPE_KEY # create or replace; prompts for the value
spool secret list # names + when set, never values
spool secret get STRIPE_KEY # is it set, and since when
spool secret delete STRIPE_KEY

set reads the value from stdin, or prompts for it without echo on a terminal. No argument takes the value, so it can’t land in your shell history.

Secrets are write-only. Once set, no CLI command, API call or console page returns the value, and get answers only whether the name exists and when it was last set. Jobs are the only reader, and logs mask what they print.

To change a value, set it again. If you have lost one, rotate it at the provider and set the new one. Listing needs viewer on the project, setting and deleting need admin. See roles.

In the console, Settings > Connections > Secrets has the same surface: the names for anyone who can see the project, replace and delete for admins.

Every project secret is injected as an environment variable into every pipeline job and every shell and code node in the project, whether it needs it or not. You reference a secret the way you’d reference any env var, as in the example at the top.

In a polyglot script the same variable is in scope, both in the script process and in the commands a spool step runs:

#!/usr/bin/env bash
spool step charge -- ./bin/charge --key "$STRIPE_KEY"

A workflow-set env: value wins over a secret of the same name, so you can shadow a secret for one job without deleting it.

Some names are refused, because every job gets the secret in its environment: anything starting SPOOL_ (the engine’s own), and names that change what a process runs at startup: PATH, LD_*, DYLD_*, BASH_ENV, ENV, IFS, NODE_OPTIONS, PYTHONPATH and the like. Store the value under a name of your own and use it from the job.

For narrower access, store the value as a credential instead. A credential reaches only the jobs and nodes that name it, and an http or connector node can turn it into an auth header without the value ever appearing in the node’s config. Use one for anything that would do damage in the wrong job: a deploy key, a signing key, a token that can push.

Credentials and connected accounts covers the kinds, where each is used, and accounts connected through a provider’s consent screen.

A run started by an untrusted event (a fork pull request, a comment from someone outside the repo) still runs, so outside contributors get lint and test feedback, but it gets none of the project’s secrets. They are left out of its environment without an error, so a step that reads one sees an unset variable.

A credential the run declares is different: the step that declares it is refused, with a message naming the untrusted origin. A graph can skip its privileged nodes with if: "!run.untrusted".

Everything such a run starts is untrusted too: what it spawns or calls, and the handler runs its events reach, its run.* lifecycle events included. So a handler of yours that posts a commit status or a chat message with a credential is refused for a fork’s runs. The status spool repo connect posts is the exception: Hyperspool posts that one itself, so a fork’s pull request still gets its check.

Pull requests from a bot account (a dependency updater, say) carry no association with the repo, so they count as untrusted too. Name the bot as trusted on the webhook that receives them:

Terminal window
spool webhook set github --manifest github --trust-actor 'renovate[bot]'

--trust-actor applies to the author of the pull request, never waives the fork check, and can be repeated. Setting the webhook again keeps its signing key, so nothing changes on the forge’s side.

Injected secret and credential values are masked wherever a job writes them to the log (a stray echo, set -x, a tool that prints its config): they show as ***, chunk by chunk in the live stream and completely in the stored output.

Masking is best-effort against echoed values. Values shorter than six characters aren’t masked, since they would match ordinary output. It can’t catch a value you’ve transformed first (base64-encoded, sliced, hashed), so don’t print secrets on purpose.

Masking applies to what a job writes to its log. An artifact is opaque bytes and nothing looks inside it. If you spool artifact put a directory that contains a .env, a kubeconfig or a service-account JSON, anyone who can read the project’s artifacts can read that file.

So keep secrets out of what you upload, or encrypt the payload first:

#!/usr/bin/env bash
# The build directory, minus anything that shouldn't travel.
rm -f dist/.env dist/*.pem
spool artifact put dist

Secrets, credentials, state cells and run history are sealed with a key of your org’s own, which is in turn sealed by a service master key. One org’s key can’t open another org’s data.

Artifacts are stored as uploaded, without that sealing.