Secrets & environments
Secrets and environment variables are the entire credential story: there are no per-service connect flows to manage. Declare a name, fetch it in code, and the engine worries about where the value lives. When one workflow needs different values for staging and production, group those values into an environment and pick one per run. For your own cloud accounts, skip stored keys entirely with OIDC cloud access.
Secrets
Declare, then get
{
"slug": "morning-digest",
"title": "Morning Digest",
"triggers": [{ "kind": "cron", "expr": "0 9 * * 1-5" }],
"permissions": { "secrets": [{ "name": "GITHUB_TOKEN" }] }
}import { secrets } from "@boardwalk-labs/workflow";
export default async function run() {
const token = await secrets.get("GITHUB_TOKEN");
// ...
}The declaration is an allowlist: secrets.get() on an undeclared name fails, and so does a run whose declared secret is missing, with an error that says exactly what to set. Fail-closed, both ways. A secret ref is just { name }; there is nothing else to wire.
Where secrets resolve from
- Boardwalk:the org's secrets vault, managed in the dashboard (Settings → Secrets), via
boardwalk secrets set NAME, or the API. Values are encrypted at rest and released to a run only for the names its manifest declares. - Self-hosted:the server's environment. Your hardware, your values.
The program code is identical in both.
The redaction guarantee
Secret values live only in your deterministic code; they are redacted from everything the model sees: prompts, tool arguments, tool results, the transcript. Fetch with the token, then hand the model the data, never the credential. Because the model can't see a secret, prompt injection can't exfiltrate one. The same guarantee covers a per-run OIDC id-token, which is redacted like a secret.
Secrets in env vars
"env": {
"NPM_TOKEN": "${{ secrets.NPM_TOKEN }}"
}When a subprocess needs a credential (a CLI you shell out to, for instance), the descriptor's env field may reference a declared secret. The reference must be the whole value, exactly as above; partial interpolation inside a longer string is not supported. env is declared inline in the descriptor; for values that differ between staging and production, set a variable on an environment instead.
Environments
An environment is a named bag of org configuration, such as production or staging. Each holds its own secrets (in the vault) and its own non-secret variables. A run executes in exactly one environment and resolves both from it. Below every named environment sits the organization base: the values that apply when a run targets no environment, and the fallback for any name an environment doesn't override.
The workflow does not name an environment. Nothing in the package changes; the same deployed workflow runs against production or staging depending only on which environment you pick when you triggerit. Create and manage environments in the dashboard (Settings → Environments) or via the API.
Non-secret variables
A variable is non-secret configuration (a base URL, a feature flag, an account id) that the platform injects into the run as a process environment variable. Read it the ordinary way:
const region = process.env.AWS_REGION_NAME;
const apiBase = process.env.API_BASE_URL;Unlike secrets, variables need no manifest declaration and no allowlist: they are not sensitive, so the value is visible in logs and is not redacted from the model. Set them per environment (or on the org base) right next to that environment's secrets. Your program owns process.envoutright: any name is yours to set, and the platform's own credentials never appear there, so nothing can collide.
Per-environment overrides
The same name can resolve differently per environment: a value defined on an environment overrides the organization base of the same name, and a name an environment doesn't define falls through to the base, all from the same secrets.get("DEPLOY_KEY") or process.env.API_BASE_URL in your code:
| Name | Defined on production | Defined on the org base | A run in production gets | A run with no environment gets |
|---|---|---|---|---|
DEPLOY_KEY (secret) | yes | yes | production's value | the base value |
API_BASE_URL (variable) | no | yes | the base value (falls through) | the base value |
The allowlist still applies regardless of environment: a secret a workflow never declares in permissions.secrets is never resolvable, in any environment.
Selecting an environment
Pick the environment when you start a run, by name. A manual or webhook trigger takes an environment alongside the input; a schedule carries the environment every fire runs in. Omit it and the run uses the organization base.
POST /v1/orgs/:slug/workflows/:id/runs
{
"environment": "production",
"input": { "ref": "refs/heads/main" }
}A child started with workflows.call() or workflows.run()inherits its parent's environment, so a call tree resolves the same secrets and variables end to end. Triggering from the CLI without --environment runs in the organization base; choose a specific environment from the dashboard, the API, or a schedule. Inside the run, context.environment carries the selected environment's id and name (null for the org base), so a program can guard an action that should only happen in production.
Cloud access (OIDC)
A workflow run can prove its identity to your own cloud and receive short-lived credentials in return, with no AWS, GCP, or Azure keys stored anywhere. The same pattern GitHub Actions uses to deploy without secrets: your cloud trusts Boardwalk's OIDC issuer, and each run mints a signed id-token that says exactly which org, workflow, and run is asking.
How it works
The workflow declares permissions.id_token: "write" in its workflow.jsonc descriptor. The program calls auth.idToken(audience)(an ordinary SDK import), which mints a signed JWT (RS256, 15-minute expiry) asserting the run's identity, then exchanges it at your cloud's federation endpoint (AWS AssumeRoleWithWebIdentity, GCP workload identity, Azure federated credentials) for short-lived credentials scoped to a role you control. The token is minted fresh on every call and is redacted from everything the model sees, like a secret.
Grant the permission
{
"slug": "s3-report",
"title": "S3 Report",
"triggers": [{ "kind": "cron", "expr": "0 7 * * 1" }],
"permissions": { "id_token": "write" }
}import { auth } from "@boardwalk-labs/workflow";
const jwt = await auth.idToken("sts.amazonaws.com");Without the grant, auth.idToken() fails with an error naming the missing permission. The audience is the party you intend to present the token to; for AWS it is sts.amazonaws.com.
Set up AWS
One-time, in your AWS account: create an IAM OIDC identity provider for the Boardwalk issuer, then a role whose trust policy accepts tokens from it.
aws iam create-open-id-connect-provider \
--url https://oidc.boardwalk.sh \
--client-id-list sts.amazonaws.comThe role's trust policy pins which runs may assume it. IAM can match on aud and sub; the sub claim is org:<org id>:workflow:<workflow id>:run:<run id>, so pin your org (or a single workflow) with a pattern. Pin the stable ids, never display names: boardwalk whoami prints your org id, and boardwalk workflows list --json prints workflow ids.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::<account>:oidc-provider/oidc.boardwalk.sh" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": { "oidc.boardwalk.sh:aud": "sts.amazonaws.com" },
"StringLike": { "oidc.boardwalk.sh:sub": "org:<your org id>:workflow:<workflow id>:*" }
}
}]
}Grant the role only what the workflow needs; the credentials it issues carry the role's permissions and expire on their own.
Exchange the token
AssumeRoleWithWebIdentity is an unsigned call (the token is the authentication), so plain fetchworks; the AWS SDK's @aws-sdk/client-sts works the same way if you prefer it. Ordinary code is enough: a suspended run resumes with its exact program state, so the credentials you hold survive the wait (mint fresh ones if they expired while you waited):
import { auth } from "@boardwalk-labs/workflow";
export default async function run(input, context) {
const jwt = await auth.idToken("sts.amazonaws.com");
const params = new URLSearchParams({
Action: "AssumeRoleWithWebIdentity",
Version: "2011-06-15",
RoleArn: "arn:aws:iam::<account>:role/<role>",
RoleSessionName: `bw-${context.runId}`,
WebIdentityToken: jwt,
});
const res = await fetch(`https://sts.amazonaws.com/?${params}`, {
method: "POST",
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error(`STS: ${res.status}`);
const body = await res.json();
const creds =
body.AssumeRoleWithWebIdentityResponse.AssumeRoleWithWebIdentityResult.Credentials;
// ... use creds with any AWS SDK client
}The returned AccessKeyId / SecretAccessKey / SessionToken plug into any AWS SDK client. Mint a new id-token whenever you need one; a run that slept for an hour just calls auth.idToken() again.
The claims
{
"iss": "https://oidc.boardwalk.sh",
"sub": "org:<org id>:workflow:<workflow id>:run:<run id>",
"aud": "sts.amazonaws.com",
"org_id": "...", "workflow_id": "...", "workflow_version_id": "...", "run_id": "...",
"trigger_kind": "cron", "actor_type": "user", "runs_on": "boardwalk/linux",
"stage": "production", "exp": 900
}Everything a policy needs to reason about which run is asking: the org, the workflow, the exact run, how it was triggered, and who triggered it. AWS trust policies match aud and sub; clouds that support custom claim mapping (GCP, Azure) can use the rest directly. Verification keys are public at https://oidc.boardwalk.sh/.well-known/jwks.json.
When to use stored keys instead
Federation needs a one-time trust setup in the target cloud. For a service that doesn't support OIDC federation, or a quick integration where that setup isn't worth it, store a credential as a secret and read it with secrets.get(): the same redaction guarantee applies. Prefer federation for your own cloud accounts; it removes the long-lived key entirely.