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 }}"

One key is refused: correlation_id. A secret_read step with key: correlation_id, or named correlation-id, fails compilation, so nexus validate and publishing both reject it: ctx.correlation_id is the platform’s trace id and goes into the log events of the execution, which is no place for a secret. Pick another key. A flow published before this refusal keeps running, but a step whose key resolves to correlation_id reads no environment variable and writes nothing; it leaves a WARN line, flow-written correlation id not adopted, with reason="secret" and without the value.

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
tls_ca_cert — Path to a PEM file of CA certificates; when set, the only CA trusted for the token endpoint
tls_client_cert — Path to a PEM file with the client certificate (and its chain) presented to the token endpoint
tls_client_key — Path to the PEM private key of tls_client_cert; the two keys go together

Every key but the three tls_* paths 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 five template keys read the environment only through {{ ctx.<var> }}: a credential read with secret_read, or a config: key such as a token URL. An environment variable written directly in the text is refused at publication by nexus validate and nexus deploy, naming the step and the key; see What a flow may read. A value a {{ }} produced is sent as it is and never reads the environment.

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.

Private CA and client certificate

The three tls_* keys follow the rules of the same keys on http_egress, described under TLS: file paths, not templates, read again at every execution; a named CA replaces the default roots; a certificate and its key are a pair, and a step that names one without the other is refused at publication; the token_url must be https://. When the material cannot be used the step fails, naming the key and never the path, and the token request is not sent: there is no fallback to a call without the material.

## Step: get-token
effects: [oauth2_token]
token_url: https://auth.partner.internal/oauth2/token
client_id: orders-app
tls_ca_cert: /etc/nexus/tls/partner-ca.pem
tls_client_cert: /etc/nexus/tls/orders-app.crt
tls_client_key: /etc/nexus/tls/orders-app.key

A certificate in place of a secret. A step with tls_client_cert: and no client_secret: sends client_id and no client_secret parameter at all, which is how a client authenticated by its certificate presents itself (RFC 8705 §2). With client_secret: the form also carries the secret. A step without tls_client_cert: and without client_secret:, one that names only tls_ca_cert: included, sends an empty client_secret, as before.

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.

On a step with tls_* keys the key also covers the client identity: a digest of the contents of the files those keys name. Two steps that differ only in their client certificate never receive each other’s token, and a replaced certificate costs one token request at the next execution.

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:

Aspect 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, but the process environment does not change after start-up, so a rotated secret takes effect after 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.