Skip to content

Secrets and OAuth2

Two effects put credentials into a flow without writing them in the flow file: secret_read takes a value from the environment, and oauth2_token obtains an access token.

secret_read

A step with this effect reads one secret and stores it in a variable.

## Step: load-api-key
effects: [secret_read]
key: partner_api_key
Key Default Meaning
key the step name The secret’s name

The value comes from an environment variable named NEXUS_SECRET_<KEY>, uppercased, with - and . replaced by _:

key: Environment variable
partner_api_key NEXUS_SECRET_PARTNER_API_KEY
orders.token NEXUS_SECRET_ORDERS_TOKEN

Omitting key: uses the step name under the same rule, so a step named load-partner-key reads NEXUS_SECRET_LOAD_PARTNER_KEY. Naming the key explicitly is clearer and survives a step rename.

The value lands in a variable named after the key, readable as ctx.<key>:

---
flowmarkdown_version: "0.1"
flow: partner-push
tenant: acme
effects: [secret_read, http_egress]
---
## Step: load-api-key
effects: [secret_read]
key: partner_api_key
## Step: send
effects: [http_egress]
endpoint: https://partner.example.com/events
method: POST
bearer_token: "{{ ctx.partner_api_key }}"

A missing secret fails the step. The error tells the caller only that a secret of that name was not found; the environment variable’s name goes to the operational log, so a misconfiguration does not advertise your naming scheme to whoever is calling.

Set the variables before starting the server. A .env file in the working directory is read at startup — see Server and configuration.

Terminal window
$ export NEXUS_SECRET_PARTNER_API_KEY='...'
$ nexus serve --port 9090

oauth2_token

A step with this effect obtains an access token by the client-credentials grant and stores it for the next step to use.

## Step: get-token
effects: [oauth2_token]
token_url: https://auth.example.com/oauth2/token
client_id: "{{ ctx.partner_client_id }}"
client_secret: "{{ ctx.partner_client_secret }}"
scope: orders.write
Key Default Meaning
token_url — Token endpoint
client_id — Literal, or {{ ctx.<var> }} to read a variable
client_secret — Literal, or {{ ctx.<var> }} to read a variable
scope — Requested scope
grant_type client_credentials Grant type

Every key is a template, like endpoint:, which is how you keep the credentials out of the file: read them with secret_read first and interpolate the variables here with {{ ctx.<var> }}. The name inside is checked at publication — an undeclared one is refused by nexus validate and nexus deploy alike, naming the step and the key. At run time a reference that renders to nothing (null) or to an empty string fails the step before the request is built; the error names the step, the key and which of the two it was, never the value. A token request is never sent with a missing credential.

The bare spelling these keys accepted before 2026-09-16 — client_secret: ctx.partner_client_secret — is refused at publication with the fix spelled out. A flow already published with it does not start the server: nexus serve and nexus config check name the flow, the step and the key and ask for a republication (see Server and configuration).

The token lands in ctx.oauth2_token.

Caching

Tokens are cached in memory, keyed by everything that determines them: token URL, client id, scope, grant type, and a hash of the client secret. Change any of those and you get a different cache entry rather than a stale token.

A cached token is reused until shortly before it expires. The refresh margin is 30 seconds, or half the token’s lifetime when that is shorter — so a 20-second token is refreshed after 10 seconds rather than being served past its usefulness. A response with no expires_in is treated as one hour.

The cache is process-local and empty after a restart.

A complete example

Read two secrets, exchange them for a token, call an API with it:

---
flowmarkdown_version: "0.1"
flow: order-push
tenant: acme
effects: [secret_read, oauth2_token, http_egress]
---
Pushes an order to the partner API, authenticated with a client-credentials token.
## Step: load-client-id
effects: [secret_read]
key: partner_client_id
## Step: load-client-secret
effects: [secret_read]
key: partner_client_secret
## Step: get-token
effects: [oauth2_token]
token_url: https://auth.example.com/oauth2/token
client_id: "{{ ctx.partner_client_id }}"
client_secret: "{{ ctx.partner_client_secret }}"
scope: orders.write
## Step: push
effects: [http_egress]
endpoint: https://partner.example.com/orders
method: POST
content_type: application/json
bearer_token: "{{ ctx.oauth2_token }}"
Terminal window
$ export NEXUS_SECRET_PARTNER_CLIENT_ID='acme-integration'
$ export NEXUS_SECRET_PARTNER_CLIENT_SECRET='...'
$ nexus tenant create --id acme --display-name "Acme Ltd" # once per tenant
$ nexus deploy order-push.flow.md --version 1.0.0
$ nexus serve --port 9090

Note that the secret-reading steps do not change the message: they only write variables, so the message reaching push is still the request body the caller sent.

A secret is not configuration

An endpoint URL, a partner code, a base path: these come from the environment too, but they are not secrets, and reading one should not cost a step. They have their own mechanism — the flow declares config: and reads ctx.<KEY> directly, fed by NEXUS_CONFIG_<KEY>. Three differences follow from the notions being distinct:

secret_read config:
How it is read a step, writing a variable declared in front matter, read as ctx.<KEY>
In the log masked not masked
When the value is read on every execution, so a rotated secret takes effect without a restart captured once at start-up, so changing it requires a restart

The other consequence of declaring is that a missing configuration value stops nexus serve from booting, naming the flow and the variable — where a missing secret fails the step, on the first message that needs it.

Not supported

Other OAuth2 grants — authorization code, refresh token, password — are not implemented. Only client_credentials works.

Secrets come from the environment only. There is no integration with an external secret manager, and no key-value store inside the platform: the kv_read and kv_write effect names are accepted by the compiler but have no handler, so a step declaring one fails at run time. See The effect catalog.