#Secrets
A secret (an API key, a provider token) is stored write-only and is never returned by any API again. Workflows and apps don't read secrets directly: they read a reference you wire into their config, and the value is injected into server-side execution only when an action runs. The model is config-wiring:
- Store the value under a scope.
- Wire a reference —
{{ secrets.<scope>.<NAME> }}— into config. The presence of the reference in config is the read authorisation. - Read the config key from the definition. A definition never names
secrets.*itself.
Authenticate with a Personal Access Token (HTTP Basic auth) — enter it once in the Authorize bar above and every Try it below uses it.
#Scopes
Every secret lives in exactly one scope, and every reference names the scope it
reads from. The scope decides who shares the value and how the runtime
resolves the scope_id when a reference is read:
| Scope | Reference | Who shares the value | scope_id at read time |
|---|---|---|---|
tenant |
{{ secrets.tenant.NAME }} |
Everyone in the tenant. | The tenant. |
connection |
{{ secrets.connection.NAME }} |
Every user arriving through one provider account (one registered provider organisation; a company may hold several). | The caller's provider organisation (implicit). |
user |
{{ secrets.user.NAME }} |
One person, across every provider they arrive through. | The caller (implicit). |
app |
{{ secrets.app.NAME }} |
One app activation, across its providers. | The current activation (implicit). |
team |
{{ secrets.team.<TEAM_ID>.NAME }} |
The members of one team. | The literal team id in the reference (explicit). |
The implicit scopes (connection, user, app) make one wired reference
resolve to a different value per caller, so the same config serves every user,
provider account, or activation with its own credential and nothing leaks across
them. team is always explicit because a user may belong to many teams. NAME
is UPPER_SNAKE.
#1. Store the value
Store a value under a name. The default scope is tenant; pass scope (and
scopeId — camelCase, unlike most fields — for connection / team / app)
to target another. Storing the same name in the same scope again overwrites it —
that is rotation.
POST/access/secretsStore a secret valueAPI docs ↗Try it
The value is encrypted at rest and never comes back out of the API —
GET /access/secrets lists names only, and no role or grant can retrieve a
stored value. Values surface only inside a running workflow or app action.
Writing a value under a shared scope (tenant, connection, team, app)
needs a secret write grant for that scope (a tenant admin holds it for every
scope). A user's own user-scoped value needs no grant — see
Secrets set from inside an app.
#2. Wire a reference into config
Put the scoped reference into a named slot of the config the definition runs
under. For a workflow, that is the tenant config's env:
PUT/workflows/{id}/configWire a secret into a workflow's envAPI docs ↗Try it
For an app, that is the app config, an integration config, or a principal config:
PUT/apps/{specId}/configWire a secret into an app's configAPI docs ↗Try it
Which config may carry which reference:
| Config | Written by | May reference |
|---|---|---|
Workflow tenant config (env) |
Tenant admin | Any scope. |
| App config / integration config | Tenant admin | tenant, connection, app, user — never team. |
company principal config |
Admin / granted user | {{ secrets.connection.* }} only. |
team principal config |
Admin / granted user | {{ secrets.team.<this team's id>.* }} only. |
user principal config |
The user | {{ secrets.user.* }} only. |
An app or integration config is shared by everyone using the activation, so
a team reference is refused there (422,
details.code: "SECRET_REF_SCOPE_VIOLATION"): it carries no membership check
and would resolve for any caller. Wire team-scoped secrets through a team
principal config instead.
A principal config may wire only references of its own scope; a tenant
reference in a principal config, or a reference whose team id differs from the
config's team, is rejected the same way. The same rule holds at read time,
whatever wrote the config: each slot grants only its own scopes (the user
config only user references, company only connection, team only team,
and the app and integration configs the shared scopes), so a config edited
outside the app to carry a foreign reference grants nothing — the reference
reaches the job input as [secrets reference removed].
A bare, scope-less {{ secrets.NAME }} never resolves — but where it is
caught differs. An app, integration, or principal config rejects it on write
(422, details.code: "INVALID_SECRET_REF"). A workflow's env accepts the
write, and at run time the reference reaches the step's input as
[secrets reference removed]: the run goes on without the credential and
reports no error for it. A 200 from PUT /workflows/{id}/config is not
confirmation that the references in it are well-formed — check that each one
names its scope.
#3. Read the config key from the definition
The workflow, job, action, or app spec reads the config key — never
secrets.*. A workflow reads config.env.PROVIDER_API_KEY; an app spec reads
the slot you wired, for example config.app.provider_token. An action's own
config sees only input and outputs, so the step threads the value in
through its input:
{
"kind": "actions",
"stepId": "call",
"input": { "token": "{{ config.app.provider_token }}" },
"actions": [
{
"stepId": "sync",
"name": "http",
"config": {
"url": "https://api.example.com/v1/sync",
"auth": { "type": "bearer", "token": "{{ input.token }}" }
}
}
]
}
For credentials that outbound HTTP calls use again and again, declare an auth
profile once instead. The profile reads the config slot directly, and every
http action or $http source names it:
// root of the app spec_document (in a workflow definition_json, read config.env.*)
"auth": {
"provider": { "type": "bearer", "token": "{{ config.app.provider_token }}" }
}
// any http action or $http source in the same document
{ "stepId": "sync", "name": "http", "config": { "url": "https://api.example.com/v1/sync", "auth": "provider" } }
Profiles cover basic, bearer, apiKey and OAuth2 client credentials. If a
profile's secret is not set, only the requests that name that profile fail
(auth profile "provider" cannot be used: its secrets are not set.; in a
workflow run's error, details.code is HTTP_AUTH_SECRET_MISSING). See
Outbound HTTP auth & external data.
At run time the wired reference resolves against the caller and the value is injected into server-side execution only. The run records the reference, not the value, as the step's input.
A
{{ secrets.* }}reference lives only in config. A workflow definition or app spec that contains one is rejected on save (422,details.code: "SECRET_REF_IN_DEFINITION"). A process definition is refused the same way, as one of its publish findings:details.findings[]carries{ path, code: "SECRET_REF_IN_DEFINITION", message }. A job definition that contains one is saved, but the reference never resolves — only a config slot's does (below) — and binding that job to a process effect slot is refused (422,details.code: "SECRET_REF_IN_EFFECT_JOB", withdetails.pathsnaming where the job holds it).
#Only a config slot's reference resolves
A reference resolves only when a config slot handed it to the job input. The
config is sealed before any expression reads it (each slot's reference becomes a
per-request token), and the evaluated job input is unsealed: every
{{ secrets.* }} text that data carried — an entity attribute, an $http row,
a trigger payload, a form value, an effect's earlier output, or a reference an
expression rebuilt from pieces — is replaced by [secrets reference removed],
and only the sealed tokens map back to their references. Nothing can escalate
past what the tenant wired. This holds for $http and $job sources, actions
/ job steps, a process effect's input, a workflow job's input, and the root
auth profiles.
Only a request to a third party carries a secret's plaintext: the http,
httpCallback and llmCallback actions. Every other action is a platform sink
(an entity or attribute write, a process move, an event, a workflow start, a
file, a pipe): its expressions resolve over the job input as it was before
resolution, so a position a secret reference filled holds the reference text,
and a row built from a config secret slot stores {{ secrets.app.X }} exactly —
never the value (the action's own output shows it as
[secrets reference removed]). The one exception is key material:
uploadArtifact's encryptionKeySource and downloadArtifact's
decryptionKeySource resolve over the resolved input, so an artifact can be
encrypted with a key held as a secret; the rest of those actions stays a sink,
and the key is stored nowhere. Nothing else a sink receives is rewritten: data
that came from no secret is written as given, even when it contains a secret's
value (https://erp.example.com/voyages/123 with the base URL held as a
secret). An upstream's answer a sink copies is scrubbed as output is (below). A
setSecret value or a setPrincipalConfig write built from a config slot
stores the slot's reference text too; a per-request token is never stored.
A secret is never a query key. queryEntity, queryEntityGraph and every
other read action receive a secret's reference text, never its value, so a
filter keyed on a secret ('value': input.api_key) finds nothing. Keying data
on a secret means storing the secret as data, which is not supported: key the
query on a non-secret identifier instead — store a non-secret id (an account id,
a key's public fingerprint) on the entity and in a plain config slot, and filter
on that.
Output is redacted. Output that leaves a job ($http rows, step outputs,
the job output) has every resolved value — raw, URL- or form-encoded, base64 —
replaced by [secret redacted], and so is every credential an auth profile
built from them: a Basic header's base64 of user:pass, an oauth2 access token
the call obtained by a token exchange. Two matches are left as ordinary data: a
value under 12 characters inside a longer word (acme in acmecorp), and a
URL-shaped value — no credentials, query, fragment or path segment of 16 or more
characters — that continues as a longer URL: a path, query, fragment, port or
longer host follows (a row's link to a resource at that host). Followed by
punctuation, a quote or the end of the text, it is the value itself and is
replaced. Errors and the action log replace every occurrence. A value under four
characters is not redacted — a known limit: it would mangle ordinary text.
#Secrets set from inside an app
An app can let its users provide their own credentials without an admin in the loop. Two pieces compose:
- a
secretInputform field — write-only; the render shows an "is set" signal, never the value, and on submit the value may flow only into asetSecretstep; - a
setSecretstep — writes the value to the scope named in itstarget({ "scope", "scopeId"?, "name", "env"? }).
"widgets": {
"connect": {
"widgetComponent": "form",
"config": {
"header": "Connect your account",
"fields": [ { "id": "key", "label": "API key", "type": "secretInput", "target": { "scope": "user", "name": "EMAIL_API_TOKEN" } } ],
"submit": {
"label": "Save",
"command": [
{ "kind": "setSecret", "target": { "scope": "user", "name": "EMAIL_API_TOKEN" }, "value": "{{ outputs.form.key }}" },
{ "kind": "notify", "level": "INFO", "message": "Connected" },
{ "kind": "refresh" }
]
}
}
}
}
A user's own user-scoped write needs no grant; a connection / team / app
write needs the secret write grant for that scope. The render context's
secretsSet lists the names of the secrets already set, grouped by scope
(secretsSet.user, secretsSet.connection, …), so a page can gate the form:
"layout": [
[{ "$if": "$not('EMAIL_API_TOKEN' in secretsSet.user)", "$then": "connect" }],
["inbox"]
]
The wired reference — "emailToken": "{{ secrets.user.EMAIL_API_TOKEN }}" in
the app config — is the same for every user; each user's own value resolves when
they run the action.
#Rotate, list, and delete
- Rotate —
PUT /access/secretswith the samename(and scope) and a newvalue, orPOSTagain. Wired references pick up the new value on the next run. Sets are idempotent on the stored key(scope, scope_id, env, name)— two same-named values under different connections, teams, or users are distinct secrets, not a collision. - List (names only) —
GET /access/secrets, optionally?scope=…. - Delete —
DELETE /access/secrets/{name}(add?scope=&scopeId=for a non-tenant scope; aconnection,teamorappdelete withoutscopeIdis a400, and an unknown name is a404). A step whose input reads a reference to a deleted value fails before its actions run (400,Missing secrets.,details.missing_secretsnaming the reference) — never a silent empty. - Revoke access without deleting — remove the reference from config; the action stops receiving the credential.
#API reference
Every secret operation, with full schemas and an interactive console, is in the API reference.