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-keyeffects: [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-pushtenant: acmeeffects: [secret_read, http_egress]---
## Step: load-api-keyeffects: [secret_read]key: partner_api_key
## Step: sendeffects: [http_egress]endpoint: https://partner.example.com/eventsmethod: POSTbearer_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.
$ export NEXUS_SECRET_PARTNER_API_KEY='...'$ nexus serve --port 9090oauth2_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-tokeneffects: [oauth2_token]token_url: https://auth.example.com/oauth2/tokenclient_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-pushtenant: acmeeffects: [secret_read, oauth2_token, http_egress]---
Pushes an order to the partner API, authenticated with a client-credentials token.
## Step: load-client-ideffects: [secret_read]key: partner_client_id
## Step: load-client-secreteffects: [secret_read]key: partner_client_secret
## Step: get-tokeneffects: [oauth2_token]token_url: https://auth.example.com/oauth2/tokenclient_id: "{{ ctx.partner_client_id }}"client_secret: "{{ ctx.partner_client_secret }}"scope: orders.write
## Step: pusheffects: [http_egress]endpoint: https://partner.example.com/ordersmethod: POSTcontent_type: application/jsonbearer_token: "{{ ctx.oauth2_token }}"$ 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 9090Note 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.