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 output, so they don’t end up in the database as plaintext or in your logs.

Terminal window
cryo secret set STRIPE_KEY sk_live_… # create or update
cryo secret list # names + when set, never values
cryo secret get STRIPE_KEY # print one value (needs member+)
cryo secret rm STRIPE_KEY

cryo 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.

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 bash
cryo 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.

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.

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/*.pem
cryo artifact put dist

Unlike 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.

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.

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 stored
as a api_key - the request would be sent with the wrong kind of auth and
rejected by the service

That 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:

Terminal window
$ 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.

Terminal window
cryo credential set github --kind bearer --token - # token from stdin
cryo 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 secrets
cryo credential list # names + kinds, never values
cryo credential rm github

A 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.

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:

Terminal window
cryo submit agent.py --as script --credential ANTHROPIC_KEY=anthropic

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.

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.

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.

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:

Terminal window
cryo account list # name, provider, the account on the far side, status
cryo account rm github # forget it here, and revoke it there if the provider allows

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/shell node 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.

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.

Connecting is only offered where an operator has set up a provider, so a fresh deployment shows none. Two environment variables:

Terminal window
CRYOSLEEP_CONNECTED_ACCOUNTS_REDIRECT_URI=https://cryosleep.example.com/connected-accounts/callback
CRYOSLEEP_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.

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.