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 output, so they don’t end up in the database as plaintext or in your logs.
Managing them
Section titled “Managing them”cryo secret set STRIPE_KEY sk_live_… # create or updatecryo secret list # names + when set, never valuescryo secret get STRIPE_KEY # print one value (needs member+)cryo secret rm STRIPE_KEYcryo secret list shows names and timestamps only. There is no command
that dumps every value. Managing secrets needs admin on the project;
reading a single value with get needs member. See
roles.
How they reach a job
Section titled “How they reach a job”Every project secret is injected as an environment variable into every
steps: and script: job in that project — every job, not only the
ones that need it. Where that breadth matters, a
connector credential declared on the job is
the narrower alternative. You reference a secret the way you’d
reference any env var:
jobs: deploy: steps: - name: push run: curl -H "Authorization: Bearer $STRIPE_KEY" https://api.example.com/…In a polyglot script the same variable is in scope, both in the script
process and in the commands a cryo step runs:
#!/usr/bin/env bashcryo 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.
Masking
Section titled “Masking”Injected secret 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 captured
output. Masking is best-effort against echoed values. It can’t catch
a value you’ve transformed first (base64-encoded it, sliced it, hashed
it), so don’t print secrets on purpose.
Artifacts are not scanned
Section titled “Artifacts are not scanned”Masking applies to what a job writes to its log. An artifact is opaque
bytes, and nothing looks inside it. If you cryo artifact put a
directory that contains a .env, a kubeconfig, or a service-account
JSON, you have stored that file, and anyone who can read the project’s
artifacts can read it.
There is no masking that would help. The control plane never holds the bytes (they move directly between your job and object storage), and guessing at which bytes in an archive are secret would be wrong in both directions.
So: keep secrets out of what you upload, or encrypt the payload before you upload it.
#!/usr/bin/env bash# The build directory, minus anything that shouldn't travel.rm -f dist/.env dist/*.pemcryo artifact put distUnlike secrets and state cells, artifacts are not encrypted by cryosleep. Secrets are sealed with your org’s key before they are written; an artifact is stored as the bytes you uploaded. Whether the storage behind it encrypts at rest is a property of the bucket your deployment points at, not something cryosleep does for you.
So a credential in an artifact is readable by anyone who can read your project’s artifacts, and by anyone who can read the bucket. Secrets and credentials already have a home that is sealed per org and injected at claim time. Use that one.
Connector credentials
Section titled “Connector credentials”A credential is a typed cousin of a secret: it knows how to turn itself into auth, so whatever needs it references it by name instead of assembling a header or wiring up an env var by hand.
Five things take one:
| Where | Field | What arrives |
|---|---|---|
http node |
credential: |
an auth header, or the secret spliced into the URL |
A connector node (slack.post_message, anthropic.messages, …) |
credential: |
the same, on the request its action builds |
code / shell node |
credentials: |
environment variables (ENV_VAR -> name) |
| A pipeline, at workflow or job level | credentials: |
the same, for that job |
A workflow node’s from_repo: |
credential: |
the token used to read the repo |
The rest of this section is written around the http node because that
is where the header machinery lives; the sections after it cover the
env-var form and the pipeline surface. A
connected account fits every row of that table
too, and is what to reach for when the thing you would otherwise paste
is somebody’s personal token.
Finding out what a graph needs
Section titled “Finding out what a graph needs”Saving a graph checks the credentials it names, and refuses one that is missing or stored as the wrong kind:
node "notify" needs credential "matrix" to be a bearer, but it is storedas a api_key - the request would be sent with the wrong kind of auth andrejected by the serviceThat answers one problem at a time, and only once you have committed to a shape. To get the whole list first - which is what you want before wiring anything up, and what an assistant composing a graph needs to hand back a checklist:
$ GET {scoped}/graphs/nightly-report/requirements{ "ready": false, "credentials": [ { "node_id": "notify", "name": "matrix", "required_kind": "bearer", "stored_kind": "api_key", "state": "wrong_kind" }, { "node_id": "fetch", "name": "api-token", "state": "missing" } ]}state is one of ok, missing, wrong_kind, conflict (one name
held by both a credential and a connected account, which resolves to
neither) or needs_reconnect (an account whose grant is gone). The
save-time refusal and this list come from the same resolver, so the
editor cannot tell you a graph is fine and the save then refuse it.
cryo credential set github --kind bearer --token - # token from stdincryo credential set posthog --kind api-key --header X-API-Key --value phx_…cryo credential set registry --kind basic --username ci --password -cryo credential set tg-bot --kind url-token --token - # Telegram-style path secretscryo credential list # names + kinds, never valuescryo credential rm githubA url-token credential replaces a literal {credential} placeholder
in the node’s url at dispatch - for APIs that carry the secret in the
path instead of a header (api.telegram.org/bot{credential}/sendMessage,
Discord webhook URLs).
Reference one from a canvas http node (or the raw graph JSON):
{ "id": "create_issue", "type": "http", "config": { "url": "https://api.github.com/repos/me/app/issues", "method": "POST", "credential": "github" } }The definition, the run history, and the queue carry only the name; the
value becomes an Authorization (or custom) header in the instant the
request is dispatched. There is no read-back: list shows names and
kinds, and nothing returns a stored value. An explicit headers: entry
in the node config wins over the credential’s header of the same name.
A missing credential fails loudly: a saved graph that names one that doesn’t exist is rejected at submit (naming the node), and a dynamically-resolved name that matches nothing fails the node’s request before anything is sent - never falling back to an unauthenticated call. Credentials live in the same per-org envelope encryption as secrets, and the web UI manages them on the Connections page.
Credentials in code and shell nodes
Section titled “Credentials in code and shell nodes”A code or shell node can name credentials to receive as environment
variables - for a script that talks to a database or an API by hand
instead of through an http node. The credentials map is
ENV_VAR -> credential_name:
{ "id": "sync", "type": "code", "config": { "script": "psql \"$DB_URL\" -c 'select count(*) from orders'", "credentials": { "DB_URL": "prod-db" } } }At claim the named credential’s secret becomes $DB_URL, resolved and
masked on the same path an http node’s header takes: the definition
and the queue carry only the name, the value is injected in the instant
before the job runs, and it’s redacted from the live log. A named
credential overrides a project secret or literal env of the same
variable (naming it is the more specific intent), and a missing one
fails the node rather than running with the variable unset. The value
is the credential’s raw secret: the token for bearer/url-token, the
key for api-key, username:password for basic. Untrusted-event
runs get no credentials, the same as project secrets.
A top-level script (cryo submit --as script, cryo run) takes the
same injection with a repeatable --credential flag:
cryo submit agent.py --as script --credential ANTHROPIC_KEY=anthropicCredentials in a pipeline
Section titled “Credentials in a pipeline”A pipeline job names credentials the same way, in the document rather than on the command line:
credentials: GITHUB_TOKEN: forge-status # every job in this pipeline
jobs: publish: credentials: REGISTRY_PASS: zot-push # this job as well steps: - name: push run: skopeo copy --dest-creds "ci:$REGISTRY_PASS" …Workflow-level entries reach every steps: and script: job; a job
entry with the same variable wins, exactly as env: does. Everything
after the declaration is the injection described above - resolved at
claim, masked in the log, missing means the job fails, and a job that
declares one on an untrusted-event run fails too rather than running
without it.
Two things differ from env:. The value is a credential NAME, not a
secret and not an expression: it is resolved when an agent claims the
job, long after the document was rendered, so ${{ … }} there is
rejected at compile rather than failing mid-run. And --credential is
for scripts only — a pipeline submit that passes it is refused, because
the flag names no job and a pipeline’s credentials belong to one.
The reason to prefer this over a project secret is scope. A project secret is in every job’s environment whether that job needs it or not; a declared credential is in the jobs that asked. For anything that would be damaging in the wrong job — a deploy key, a signing key, a token that can push — the declaration is worth the extra line.
Credentials on a connector node
Section titled “Credentials on a connector node”A connector node names one the same way an http node does, because the
action lowers to the same request. What differs is that the manifest
decides the kind and whether it is required at all, so a node missing one
the connector requires is refused when you save the graph rather than
failing at dispatch:
{ "id": "notify", "type": "slack.post_message", "config": { "with": { "channel": "#deploys" }, "input": { "text": "shipped" }, "credential": "slack-bot" } }Store it as the kind the connector’s API expects.
Connectors lists what each built-in declares, and
cryo credential list shows the kinds you have. Nothing compares the
two: a credential becomes auth according to how you stored it, so
storing a basic where the API wants a bearer sends a Basic header
and the API rejects it, rather than failing at save.
Credentials for fetching a repo
Section titled “Credentials for fetching a repo”A workflow node running a git-stored pipeline takes one for the fetch
itself:
{ "id": "ci", "type": "workflow", "config": { "from_repo": { "path": ".cryo/deploy.yaml", "repo": "me/private-app", "credential": "gh-read" } } }Both repo and credential are required. There is no project-wide repo
or token to fall back on: nothing stores which repo a project belongs to,
so every fetch says which repo it means and which stored credential can
read it. The credential must be a bearer or a url_token.
Connected accounts
Section titled “Connected accounts”A connected account is an identity on another system that a project borrows, granted through that system’s own consent screen instead of by pasting a token. Everywhere a credential goes by name, a connected account goes the same way - the five surfaces in the table above take either, and a node that names one does not know or care which it got.
Connecting one is a browser flow, because the provider needs to ask the person granting it. On the Connections page, choose connect account, give it a name, pick the provider, and approve at the provider; you land back on the page with the account listed. From then on:
{ "id": "create_issue", "type": "http", "config": { "url": "https://api.github.com/repos/me/app/issues", "method": "POST", "credential": "github" } }The name is shared with credentials, so one project cannot have both a
credential and a connected account called github - whichever you try
to create second is refused, because a run naming it would have no way
to say which it meant.
From the terminal you can see and remove them, but not connect one:
cryo account list # name, provider, the account on the far side, statuscryo account rm github # forget it here, and revoke it there if the provider allowsWhat it acts as, and who can use it
Section titled “What it acts as, and who can use it”The account acts as the person who granted it, at the scopes they approved. That is the point of it - and the thing to be deliberate about, because it does not stay with them:
- Anyone who can submit a workflow in the project can use its access.
On a
code/shellnode or a pipeline job the token arrives as an environment variable, which the job can simply read, so treat connecting an account as handing its access to every member who can submit. Ask for the narrowest scopes the work needs. - The other system’s audit log names the granter, not the project. The Connections page records who granted each account so the question is answerable from this side too.
- An untrusted delivery - a fork pull request, an outside contributor’s comment - is refused connected accounts exactly as it is refused credentials and project secrets.
Staying alive, and what the statuses mean
Section titled “Staying alive, and what the statuses mean”A delegated token expires, so cryosleep renews it in the background before it does. You do not have to do anything, but the status on the Connections page is worth knowing:
| Status | What it means |
|---|---|
active |
Usable. The ordinary state. |
refreshing |
A renewal is in flight. Still usable - the current token is still good. |
needs_reauth |
The grant is gone (revoked at the provider, or expired past renewal). Reconnect it. |
in_doubt |
A renewal could not be confirmed and retrying it might make things worse. Reconnect it. |
disabled |
Switched off, either by hand or because the person who granted it left. |
Anything other than active or refreshing fails the nodes that name
it, with a message saying to reconnect, rather than sending a request
with a dead token and surfacing whatever the far side says about it.
Reconnecting keeps the name every workflow references, and has to land on the same account on the same provider. If you approve as somebody else, the reconnect is refused rather than quietly repointing every workflow that uses the name.
When a member leaves a project or the org, the accounts they granted are disabled - their access should not outlive their membership. Someone still there reconnects them under the same name.
Configuring providers
Section titled “Configuring providers”Connecting is only offered where an operator has set up a provider, so a fresh deployment shows none. Two environment variables:
CRYOSLEEP_CONNECTED_ACCOUNTS_REDIRECT_URI=https://cryosleep.example.com/connected-accounts/callbackCRYOSLEEP_CONNECTED_ACCOUNT_PROVIDERS='[{ "name": "github", "authorize_url": "https://github.com/login/oauth/authorize", "token_url": "https://github.com/login/oauth/access_token", "client_id": "Iv1.…", "client_secret": "…", "scopes": ["repo", "read:user"], "capabilities": { "rotates_refresh_token": false, "reuse_revokes_family": false }, "identity": { "url": "https://api.github.com/user", "id_pointer": "/id", "label_pointer": "/login" }}]'The redirect URI is one per deployment and has to match what you
registered with the provider, which is why it is configuration rather
than something a project chooses. Every URL must be https (plain
http is accepted only for localhost, so a self-hosted provider can
be tried out), and the server refuses to start if one is not - a typo
here would otherwise send a client secret somewhere in the clear.
identity is how the far-side account is recognised, as
JSON pointers into whatever
that endpoint returns. id_pointer must reach something stable: it is
what a reconnect is checked against, so a display name would let a
rename repoint the account.
capabilities describes what the provider does with refresh tokens,
and there is no safe default, so it is required:
rotates_refresh_token- a successful renewal invalidates the token that bought it.reuse_revokes_family- presenting an already-rotated token again revokes everything descended from the grant, rather than failing one request.
Together they decide what happens when a renewal cannot be confirmed: retry where that costs a failed request, stop and ask for a reconnect where it would cost the grant. Get them from the provider’s own documentation; guessing the forgiving answer for a strict provider is how accounts die.
Two optional fields are worth setting where the provider supports them:
revoke_url, so disconnecting an account also ends it on the far side
rather than only forgetting it here, and assumed_lifetime_secs, for a
provider that issues expiring tokens without saying when - without it
such an account is never renewed and its token dies unnoticed.
Encryption at rest
Section titled “Encryption at rest”Secrets never touch disk in the clear. Each org has its own encryption
key that seals its secrets; that key is in turn sealed by the server’s
master key (set with CRYOSLEEP_MASTER_KEY). Two consequences worth
knowing:
- Isolation is per org. One org’s key can’t open another’s secrets, so a single compromised key exposes one tenant, not the whole server.
- The master key is required for a durable (Postgres) deployment. Lose it and every sealed secret is unrecoverable, so keep it in your secret manager, which the process env alone is not.