Skip to main content
The secrets module lets automations securely store and retrieve sensitive values (API keys, tokens, credentials) at runtime. Values are encrypted at rest and never exposed as plaintext in automation outputs or logs. This page documents two things:
  • 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, and secrets.acquireRenewal / secrets.releaseRenewal for connectors whose provider rotates its refresh tokens.
These are two separate stores. A secret saved in Settings → Secrets is not returned by secrets.get, and a secret created with secrets.set does not appear in Settings → Secrets and cannot be read as {{secret.<name>}}. See Using Settings secrets in automations.

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 in index.yml:
Every property of the schema is rendered as an input field. These are the “main” secrets of the workspace: they have known names, the workspace configuration references them as {{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 config to reach an automation).
Once a name stabilizes, promote it to the schema so the next workspace clone has the documentation built in.
The “Additional Secrets” form persists its values to the same workspace secrets as the schema-defined ones. It is not a temporary scratchpad; entries survive reloads and pulls (unless overwritten by a pulled index.yml).

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:
The configuration is re-resolved each time the secrets are saved. Values coming from secrets are redacted from the runtime’s native events (such as 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), the secrets 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

  1. secrets.set and secrets.get return an opaque secret reference (a $secret:... string), not the actual value.
  2. When this reference is used inside a fetch instruction (in the URL, headers, body, query, or auth fields), the platform automatically resolves it to the real value just before the HTTP call.
  3. The resolved value is redacted from all logs and events.
Secret references are short-lived (a few minutes). Always call secrets.get right before using the reference; do not store it for later reuse.

Scopes

Access model — who can read a secret

Whether secrets.get (and set/delete) succeeds depends on three independent things:
  1. The scope requested — user or workspace.
  2. Whether the run carries an authenticated user — i.e. ctx.source.userId is 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.
  3. Whether the call took on one of the workspace’s own roles — assumeRole on the run instruction, which only ever helps a workspace secret (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

A workspace 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 — a secure_secrets rule in the workspace’s security.yml, carried by no role, so it applies to every member. Example (Figma connector):
The platform validates this rule strictly and forces two conditions you do not write — 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):
The width must be fixed ({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:
What a rule must prove depends on who it applies to, not on the action:
  • a rule carrying no role: lands on default, 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.
A family pattern is checked as strictly as the user-bound one: 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.
Anything holding that role can read and overwrite every credential in the family. That is the price of servicing absent users, and it is why the role should exist for that purpose alone: do not assign it to a person, and do not give it an auth block. An API key bound to it would hand every member’s credential to whoever holds the key. The platform does not prevent either — it is yours to decide.
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.
See RBAC → secure secrets for the security rules, and Automations → 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 by set or get wherever a sensitive value is needed in a fetch instruction. The platform resolves it transparently:
Resolution is supported in these fetch fields: url, headers, body, query, and auth (including auth.awsv4).
Secret references expire after a few minutes. Always retrieve the reference (secrets.get) in the same automation run where you use it.

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 by get, 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.
When the call returns 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:
  1. Call acquireRenewal on the secret holding the refresh token.
  2. On acquired, exchange renewal.ref with the provider, in a fetch with maxRetries: 0. Then store the new refresh token with ifRef: '{{renewal.ref}}'. If that write succeeds, store the new access token. If it returns version_conflict, keep what is stored and do not write the access token either. Call releaseRenewal with renewed: true last, so the calls waiting for the lease find every new token in place.
  3. If the provider refuses the refresh token for good (invalid_grant), delete it with ifRef: '{{renewal.ref}}', then call releaseRenewal with renewed: false.
  4. On renewed, read the access token again with get and retry the original call once.
  5. On retry or unavailable, ask the user to try again. On not_found without a message, ask them to reconnect. With a message, the role or its rule is missing: fix the workspace’s security.yml instead.
The new refresh token must be stored before the lease ends. Once the lease has expired, another call can take it, read the refresh token that is still stored and present it again. Give the exchange’s fetch a timeout (in milliseconds) well below the lease’s ttl: the default fetch inactivity bound is 300 seconds.Give it maxRetries: 0 as well. Without it, the fetch instruction retries by itself, with the same body, on 429, 502 and 503 and on the connection errors ECONNRESET, UND_ERR_SOCKET, EPIPE, EHOSTUNREACH and ENETUNREACH. With the platform defaults, it makes up to 4 more attempts, with 15 seconds of waiting between them in total, and an error body carrying details.retryAfter can stretch each wait to just under 30 seconds. Each retry presents the same refresh token again, possibly after the provider has already rotated it, and retries the provider asks to space out can outlast the lease, whatever its ttl. With maxRetries: 0, the exchange is sent once: the ttl then covers opening the connection (up to 10 seconds), the timeout of the answer and the writes that follow. See Sending a request only once.
The example below renews the token of the user 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>.
The caller retries its original request once when the result is 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

  1. 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).
  2. 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.
  3. 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.
Unwrapped DEKs are kept in process memory for a few minutes to avoid unwrapping on every call. They are never written to disk or to the database.

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 active key wraps the data keys of new workspaces, while older keys keep unwrapping existing ones.
The same scheme protects organization-level sensitive configuration (such as SSO settings), with one data key per organization.

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.
Losing the master keys makes every encrypted secret permanently unrecoverable. When rotating, always add a new key marked active and keep the previous ones in the keyring. Never replace or remove a key that still wraps data keys. Self-hosted operators: see Secrets encryption keys for key configuration and backup, and Recover a Lost Encryption Key if a key was lost.