- The Settings → Secrets form in the Builder UI, where workspace secrets are configured before any automation runs. These secrets are injected into the workspace configuration through
{{secret.<name>}}. - The runtime API (
secrets.set,secrets.get,secrets.delete) for automations that create or rotate secrets themselves, andsecrets.acquireRenewal/secrets.releaseRenewalfor connectors whose provider rotates its refresh tokens.
Settings → Secrets form
Open Settings → Secrets in the Builder. The form has two sections.Schema-defined secrets
The top section is generated from the workspace’s secret schema, declared inindex.yml:
{{secret.<name>}}, and someone (usually the workspace author) committed the schema to source control.
Typing a value and clicking Save Secrets sends the new values to PATCH /v2/workspaces/{workspaceId}/security/secrets. The form loads the current values when it opens. A field left empty is not sent: it leaves the existing value untouched rather than clearing it. To remove a secret, delete it (DELETE /v2/workspaces/{workspaceId}/security/secrets/{secretName}).
Additional Secrets form
The “Additional Secrets” section underneath is for secrets that are not declared in the schema. The label “Additional” is intentional: these are extras beyond the schema-defined set.
Use the Additional form when:
- You are prototyping and have not yet committed a schema.
- You need an environment-specific override (e.g. a per-deploy webhook URL) that should not appear in source.
- You want a one-off value without declaring it in the schema (it still has to be referenced from the workspace
configto reach an automation).
Using Settings secrets in automations
{{secret.<name>}} is resolved only in the workspace configuration (config.value in index.yml, and the configuration of installed apps). It is not a variable of the automation context: written inside an automation’s do, {{secret.<name>}} resolves to an empty value, without any error. A fetch then goes out with an empty header, and a condition on it is always false.
To use a Settings secret in an automation, expose it through the workspace configuration, then read the configuration:
runtime.fetch.failed). They are still ordinary values once in the automation, though: do not return them in an endpoint output or emit them in a custom event.
Use the runtime API below only for secrets an automation creates or rotates itself (OAuth tokens, refresh tokens, per-user credentials). It cannot read the secrets saved in Settings → Secrets.
Runtime API
For automations that need to create or rotate secrets at runtime (OAuth tokens, refresh tokens, per-user credentials), thesecrets module exposes set, get, and delete. acquireRenewal and releaseRenewal make sure that only one call at a time renews a given token: see Renewing a rotating token once.
This store is only written by secrets.set. It is separate from Settings → Secrets: there is no form or HTTP route to fill it, and its secrets are never available as {{secret.<name>}}.
How it works
secrets.setandsecrets.getreturn an opaque secret reference (a$secret:...string), not the actual value.- When this reference is used inside a
fetchinstruction (in the URL, headers, body, query, or auth fields), the platform automatically resolves it to the real value just before the HTTP call. - The resolved value is redacted from all logs and events.
secrets.get right before using the reference; do not store it for later reuse.
Scopes
Access model — who can read a secret
Whethersecrets.get (and set/delete) succeeds depends on three independent things:
- The scope requested —
userorworkspace. - Whether the run carries an authenticated user — i.e.
ctx.source.userIdis set. Interactive runs (a user clicking in the UI, an API call with a user token, a page render) carry a user. Cron/schedule triggers, anonymous webhooks,emit-triggered runs, and in-process cross-workspace calls do not. - Whether the call took on one of the workspace’s own roles —
assumeRoleon theruninstruction, which only ever helps aworkspacesecret (see Reading a workspace secret with no user).
Access matrix
set and delete follow the same path as get, under the same assumed role: a run that can read a rotating token can also store its replacement, and drop one the provider has rejected for good. Reading without writing would be a half-feature — the run would burn the token and fail on the next pass — and without deleting, a dead token is retried forever.
acquireRenewal and releaseRenewal follow that path too. Holding the renewal lease means writing the secret next, so it requires the right to update it: a call allowed to read the secret but not to update it fails with the same permission error as a refused set.
Reading a workspace secret with no user
Aworkspace secret is invisible to a run that carries no user: reads resolve RBAC against the caller, and a user-less trigger holds no role, so it sees nothing. That is exactly the situation of a scheduled synchronisation — the credentials the workspace’s own members deposited are unreadable to the workspace’s own sync.
Nothing about the shape of the run answers it. An unauthenticated cron and an unauthenticated HTTP call are indistinguishable, and a sync typically reaches the connector by endpoint rather than by schedule. So the workspace states the entitlement itself: it declares a role in its security.yml, and the automation takes that role on for the one call that needs it.
1. Members deposit their own credential
A connector lets its members write their own token — asecure_secrets rule in the workspace’s security.yml, carried by no role, so it applies to every member. Example (Figma connector):
workspaceId (the current workspace) and scope: workspace. The rule must obey:
A connector holding several credentials per remote account carries a discriminator with the fixed-width class (SharePoint):
{16}, never + / * / {n,m}) and the class must sit before ${user.id}: a variable-width segment next to the id would let two different (account, user) pairs spell the same name.
2. A role reads — and writes back — the whole family
The run that replays those tokens has no user to bind to, so its rule names a family of secrets instead, and it is carried by a role the workspace declares:- a rule carrying no
role:lands ondefault, which applies to every member, so${user.id}stays mandatory — it is the only thing keeping members apart; - a rule carried by a role applies to whoever takes it on, which is an automation acting for the workspace. It may name a family, for reads (a sync serves everyone) and for writes alike (a provider rotating its refresh tokens hands back a new one that must be stored for a member who is not there). A role-carried rule may also pin
${user.id}— strictly narrower, and accepted on its own terms.
The head is what a caller cannot vary, and it is what names the family — ending it on a separator is what stops
onnRefresh_ from also matching onnRefreshOther. The resulting pattern deliberately matches every member’s figRefresh_<id>: that breadth is the point, a scheduled sync must be able to reach any connected user’s token.
3. The automation takes the role on
assumeRole applies to that single call. Everything around it keeps the caller’s own rights, and nothing is carried to the automations it calls, to an emit, or across a workspace boundary. It replaces the caller’s rights rather than adding to them, so the role has to be sufficient on its own for what the call does.
Only a role the workspace declared can be assumed: owner is refused, so a developer has to state what they are opening. And whatever the role, scope: user stays out of reach — the module refuses it without a user before RBAC is consulted.
Per-user connector tokens (OAuth refresh tokens, etc.) are not stored as
user secrets. They are workspace secrets whose name embeds the user id (figRefresh_<id>), deposited under the rule above. That is why a role — which only ever reaches workspace secrets — is what lets a scheduled sync replay a given user’s token.assumeRole for the instruction.
Functions
set: Store or update a secret
Returns a
$secret:... reference string. If a secret with the same name and scope already exists, it is updated.
With ifRef, the call never creates a secret, and value is required. It returns { error: "version_conflict" } without writing anything when the secret changed since the reference was read, when the secret no longer exists or this call cannot read it, or when the reference expired or was read from another secret.
get: Retrieve a secret reference
Returns a
$secret:... reference string, or { error: "not_found" } if the secret does not exist (or has expired).
delete: Remove a secret
Returns
{ deleted: true } on success, or { error: "not_found" }. With ifRef, it returns { error: "version_conflict" } and keeps the secret when the secret changed since the reference was read, or when the reference expired or was read from another secret.
acquireRenewal: Take the renewal lease of a secret
Returns an object whose
status says what to do next:
A call that waited never takes the lease over: it answers
renewed, retry, not_found (the holder deleted a refused token) or unavailable, and the next call takes the lease. Invalid parameters return { error: "invalid_parameters" }, and scope: user without a user returns { error: "user_required" }.
On a self-hosted platform running on PostgreSQL, update the
workspaces service before the runtime: workspaces adds the columns the lease and the versions need when it starts. Until they exist, acquireRenewal and releaseRenewal answer unavailable, logging an error and emitting the event on every call, and every set or delete with ifRef answers version_conflict. Plain get, set and delete keep working. During a rolling update, a runtime of the previous version still writes values without moving their version, so for a short while an ifRef can pass over a token that such a runtime just rotated.releaseRenewal: Free the renewal lease
Returns
{ released: true } when it freed this lease, and { released: false } when the lease was no longer this one (it expired and another call took it, or the secret was deleted). It only ever frees the lease it names. If the secret store does not answer, it returns { status: "unavailable", released: false } and emits the same event as acquireRenewal: the lease then ends on its own after its ttl. Invalid parameters return { error: "invalid_parameters" }, and scope: user without a user returns { error: "user_required" }.
Using secret references in fetch
Pass the reference returned byset or get wherever a sensitive value is needed in a fetch instruction. The platform resolves it transparently:
fetch fields: url, headers, body, query, and auth (including auth.awsv4).
Complete example: storing and using an OAuth token
Renewing a rotating token once
Many OAuth providers rotate their refresh tokens: each exchange returns a new refresh token and invalidates the one presented. Some of them also treat a refresh token presented twice as a replay and revoke the whole connection, which forces the user to reconnect. That happens as soon as two calls renew the same token at the same time, for instance two tools of an agent, or a scheduled sync and an interactive call. Both read the same refresh token, both present it, and the second presentation is the replay. Storing the result carefully does not help, because the provider reacts to the presentation itself.acquireRenewal prevents it. Only one call at a time holds the renewal lease of a secret, across every instance of the runtime. The others wait for it and reuse what it renewed.
Writing only over what you read
Each reference returned byget, set or acquireRenewal records the version of the secret it was read at. A fetch still resolves a reference to the value stored when the fetch runs: only ifRef compares versions. Any later write of the value, such as a member reconnecting or another run storing a rotated token, makes the reference stale for ifRef.
Pass the reference as ifRef to set or delete to act only on the secret you read. A stale write then returns { error: "version_conflict" } instead of overwriting the newer value:
- a late rotation does not overwrite a connection the user made in the meantime;
- a refusal from the provider deletes the token it refused, never a fresh one.
version_conflict, keep what is stored: it is more recent than what this call was about to write.
The reference returned by a set without ifRef vouches for that write only. When another write of the same secret lands at the same moment, it vouches for none, and an ifRef made with it returns version_conflict: read the secret again with get before writing over it.
ifRef is checked strictly. When it is given but is not a string, for example a variable that was never set, the call returns { error: "invalid_parameters" } rather than writing unconditionally.
The renewal flow
Run this flow when the provider rejects the access token, and only then:- Call
acquireRenewalon the secret holding the refresh token. - On
acquired, exchangerenewal.refwith the provider, in afetchwithmaxRetries: 0. Then store the new refresh token withifRef: '{{renewal.ref}}'. If that write succeeds, store the new access token. If it returnsversion_conflict, keep what is stored and do not write the access token either. CallreleaseRenewalwithrenewed: truelast, so the calls waiting for the lease find every new token in place. - If the provider refuses the refresh token for good (
invalid_grant), delete it withifRef: '{{renewal.ref}}', then callreleaseRenewalwithrenewed: false. - On
renewed, read the access token again withgetand retry the original call once. - On
retryorunavailable, ask the user to try again. Onnot_foundwithout amessage, ask them to reconnect. With amessage, the role or its rule is missing: fix the workspace’ssecurity.ymlinstead.
targetUserId, under a tokenResolver role built as in Reading a workspace secret with no user, with one rule for ^acmeRefresh_ and one for ^acmeAccess_. The refresh token is stored as acmeRefresh_<user id> and the access token as acmeAccess_<user id>.
renewed, reading the access token again with get. If the provider rejects it again, do not run the flow in a loop: within reuseWindow, it answers renewed again without any exchange. The caller asks the user to try again on retry, and to reconnect on reconnect.
To check the flow, call it twice at the same time for the same user: the provider receives a single exchange, and both calls answer renewed.
Error handling
These functions return an error object instead of throwing when the error is actionable:acquireRenewal reports what happened to the lease in its status (not_found, retry, unavailable) rather than as an error, and so does releaseRenewal for unavailable: see acquireRenewal.
Use onError or a conditions block to handle these:
How secrets are encrypted
Secrets stored through this module are protected with envelope encryption. Two levels of keys are involved, and they are never stored in the same place.Lifecycle
- First secret of a workspace: the platform generates a random 256-bit DEK for the workspace, wraps it with the active KEK and stores the wrapped DEK, tagged with the KEK’s key ID (
kid). - Writing a secret: the DEK is unwrapped in memory, and the value (serialized as JSON) is encrypted with AES-256-GCM using a fresh random IV. Only the IV, ciphertext and authentication tag are stored.
- Reading a secret: the wrapped DEK is unwrapped with the KEK identified by its
kid, then the value is decrypted. The GCM authentication tag guarantees integrity: a tampered ciphertext fails to decrypt instead of returning a corrupted value.
Why two levels of keys
- A database leak alone exposes nothing. Secret values are only usable with the DEK, and the DEK is only usable with the KEK, which never leaves the platform configuration.
- Workspace isolation. Each workspace has its own DEK, so the compromise of one data key exposes a single workspace.
- Master key rotation without re-encrypting data. Every wrapped DEK records which KEK wrapped it, so several KEKs can coexist: a new
activekey wraps the data keys of new workspaces, while older keys keep unwrapping existing ones.
Secret references
The$secret:... references returned by secrets.get and secrets.set are a separate mechanism. They are short-lived tokens, encrypted with their own reference keys (SECRETS_REF_KEYS). They carry the identifier of the secret, never its value, and expire after a few minutes. Because nothing durable depends on them, rotating reference keys only requires keeping the previous key until outstanding references have expired.