Authorize

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

  1. Store the value under a scope.
  2. Wire a reference — {{ secrets.<scope>.<NAME> }} — into config. The presence of the reference in config is the read authorisation.
  3. 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", with details.paths naming 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 secretInput form field — write-only; the render shows an "is set" signal, never the value, and on submit the value may flow only into a setSecret step;
  • a setSecret step — writes the value to the scope named in its target ({ "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/secrets with the same name (and scope) and a new value, or POST again. 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; a connection, team or app delete without scopeId is a 400, and an unknown name is a 404). A step whose input reads a reference to a deleted value fails before its actions run (400, Missing secrets., details.missing_secrets naming 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.