Skip to content

Authentication

API keys: how to create, restrict and revoke them, what a flow can demand of one, and how per-key rate limiting behaves. A key is required to call a flow — see Enforcement today for exactly which routes, and for the one flag that opens them.

Creating a key

Terminal window
$ nexus keys create --tenant acme --label "order pipeline" --scopes run:orders
created API key id=3
tenant: acme
label: order pipeline
scopes: run:orders
flows: * (all flows)
key (copy now — not stored): nxk_4f1c8a2e9b7d40a1bd3e6c05f8a91237
Usage: Authorization: Bearer nxk_4f1c8a2e9b7d40a1bd3e6c05f8a91237

The raw key is printed once and is never stored. Only its blake3 hash goes into the database. If you lose it, revoke the key and create another one — there is no way to recover it, and no way for anyone with database access to read it back.

Flag Default Meaning
--tenant <t> required The tenant the key belongs to. A key is valid only for flows of that tenant.
--label <s> required Free text, shown in listings.
--scopes <a,b> * Comma-separated scopes. * grants every scope.
--expires-at <ts> none Either 2027-01-01 — the whole of that day, UTC, so the key still works on it — or an instant, 2027-01-01T00:00:00Z. A timezone offset is refused: convert to UTC first. nexus keys create prints what was stored, which for a bare day is …T23:59:59Z.
--rate-limit <rps> none Sustained requests per second. Omit for unlimited.
--flows <a,b> none Comma-separated flow names the key may call. Omit for all flows of the tenant.
--db <path> ~/.nexus/registry.db Registry database. Must be the one the server uses.

Key format and header

A key is nxk_ followed by 32 hexadecimal characters. Present it as a bearer token:

Terminal window
$ curl -X POST http://localhost:9090/flows/acme/forward-order/run \
-H 'Authorization: Bearer nxk_4f1c8a2e9b7d40a1bd3e6c05f8a91237' \
-H 'Content-Type: application/json' \
-d '{"orderId":"A-1","total":42}'

The prefix has no meaning to the server; it is there so a key is recognisable in a log or a configuration file. The Authorization header never reaches the flow — it is stripped before ctx.headers is built, so a flow cannot read it back out or forward it downstream.

Listing and revoking

Terminal window
$ nexus keys list --tenant acme
id label status flows scopes created_at
--------------------------------------------------------------------------------------------------------------
3 order pipeline active * run:orders 2026-08-04T11:02:17Z
2 partner integration active forward-order run 2026-07-30T08:41:55Z
1 old CI key revoked * * 2026-06-12T14:20:09Z

status is derived: revoked if it was revoked, expired if its expiry has passed, otherwise active.

Terminal window
$ nexus keys revoke --id 3
revoked API key id=3

Revocation is immediate and permanent — the row stays for the record, but the key stops resolving on the next request. Revoking a key that does not exist, or that is already revoked, is an error.

Scopes

A scope is an arbitrary string. The platform does not interpret it; it only checks membership. Pick names that mean something to you — run, run:orders, admin.

A key satisfies a required scope when its scope list contains that exact string, or contains the wildcard *.

Terminal window
$ nexus keys create --tenant acme --label "read-only integration" --scopes run:orders,run:invoices
$ nexus keys create --tenant acme --label "everything" --scopes '*'

A flow demands a scope with required_scope: in its front matter:

---
flowmarkdown_version: "0.1"
flow: forward-order
tenant: acme
required_scope: run:orders
effects: [http_egress]
---
## Step: forward
effects: [http_egress]
endpoint: https://orders.example.com/ingest
method: POST

A key without run:orders (and without *) is refused with 403. A flow with no required_scope: accepts any active key for its tenant, whatever its scopes.

required_scope is declared once, by the flow’s author, in its front matter — it names the capability class any caller must hold, without knowing or caring which keys will ever exist. It composes with the allowlist below: a request must pass both checks when both are set. Scope groups flows into a class one key can serve without being edited every time a new flow joins that class; the allowlist narrows a key to specific names within — or outside — any class it otherwise qualifies for. Neither implies the other: a key with the right scope is still refused if the flow isn’t in its allowlist, and a key named on a flow’s allowlist is still refused if it lacks the flow’s scope.

The scope check runs on both the synchronous and the queued endpoint. On /enqueue a key missing the flow’s scope is refused with 403 before anything is written to the queue — the message never becomes durable.

Restricting a key to named flows

Terminal window
$ nexus keys create --tenant acme --label "partner integration" \
--scopes run --flows forward-order,order-status

The key can call acme/forward-order and acme/order-status, and nothing else in the tenant. Anything else answers 403. Without --flows the key can call every flow of its tenant — that is the default, and it is worth saying out loud.

The allowlist is exact-match on the flow name. There are no patterns.

The allowlist is independent of scopes above, and both apply together: a request must pass its flow’s required_scope and the calling key’s allowlist, wherever either is set. Scope is the coarser filter, set by the flow’s author — a capability class satisfied by any key that holds it. The allowlist is the finer one, set by the operator issuing the key — useful when a key should reach only some of the flows inside a scope class it otherwise qualifies for (a partner key scoped run:meters but allowlisted to one specific meter, say), or none of a class it has no scope for at all.

Rate limiting

--rate-limit <rps> attaches a token bucket to the key.

Property Value
Algorithm Token bucket with continuous refill.
Refill rate rps tokens per second.
Capacity 2 × rps — the burst ceiling.
Tokens at first use rps, not the full capacity.
Cost One token per request.
Scope Per key. Two keys never share a bucket.

So a key created with --rate-limit 10 can spend 10 requests immediately, then sustains 10 per second, and can accumulate up to 20 tokens while idle — meaning a burst of 20 after two seconds of quiet. A key with no limit is never throttled.

The buckets live in memory. Restarting the server resets every bucket to its starting tokens.

A throttled request gets 429 with four headers:

Terminal window
$ curl -i -X POST http://localhost:9090/flows/acme/forward-order/run \
-H 'Authorization: Bearer nxk_...' -d '{}'
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785060142
X-RateLimit-Reset and Retry-After are seconds and Unix seconds respectively
Retry-After: 1
Content-Type: application/json
{"ok":false,"error":"rate limit exceeded"}
Header Meaning
X-RateLimit-Limit The configured sustained rate, in requests per second.
X-RateLimit-Remaining Tokens left, rounded down. Zero on a rejection.
X-RateLimit-Reset Unix timestamp, in seconds, at which the bucket will be completely full.
Retry-After Whole seconds until one token is available.

Response codes

Code Cause
401 No Authorization header, a header that is not Bearer <token>, or a key that is unknown, revoked, expired, or belongs to another tenant.
403 Valid key, but it lacks the flow’s required_scope, or the flow is not in its allowlist.
404 The tenant is not registered, or has been disabled — with exactly the body of a flow that does not exist.
429 Valid key, over its rate limit.
503 The gate could not reach storage to decide. Never a pass, and never a 401.

The 401 body does not distinguish between the causes.

A disabled tenant answers like an absent one, deliberately. Whether an organisation exists on this installation is not something a caller without a key gets to learn. This holds on GET /flows/{t}/{n}/wsdl too, even though that route is public: the route still answers without credentials, but only for a tenant that is registered and enabled. For any other tenant it answers 404 with the body of a flow that does not exist, including in place of the “no SOAP configuration” answer. The check runs in the handler rather than at the gate, so it also holds under --no-auth.

A tenant is a registered entity

A tenant is created before anything can be deployed to it or keyed for it:

Terminal window
$ nexus tenant create --id acme --display-name "Acme SRL"
$ nexus tenant list
$ nexus tenant disable --id acme # marks; does not delete, does not touch keys
$ nexus tenant enable --id acme

nexus deploy and nexus keys create refuse an unregistered or disabled tenant, naming the command that repairs it. Implicit creation on deploy is refused on purpose: a typo would silently become a real tenant.

disable marks, it does not delete — an operator has to be able to see what they stopped. enable clears the mark and restores nothing else: disable never touched the keys, so all of them start authenticating again at that moment, including forgotten ones. If the stop was a reaction to a leaked credential, revoke the key before re-enabling; enable is not a review.

All three acts enter the audit chain — tenant.registered, tenant.disabled, tenant.enabled — attributed to the local operator. Nothing is written when nothing changed: a second disable succeeds but leaves no row.

The gate runs before the body

On /run and /enqueue the decision is taken before a single byte of the request body is read. An unauthenticated caller causes two indexed queries and nothing else — no registry read, no CAS read, no parsing.

Two consequences:

  • A caller with no key announcing an oversized body gets 401, not 413. The size ceiling is internal platform state and is not disclosed before authentication.
  • The API key is checked first, the webhook signature second — the key needs no body, the signature does. With the gate on, a webhook sender already needs a key; with --no-auth the gate does not run and X-Nexus-Signature remains the only control, as before.

Every refusal leaves an access_denied event in the L1 log with the operator-facing reason, and a refused request does not emit received — a trace that begins and never ends is worse than no trace. It is not a fault either: faults count as the flow’s error rate, so a port scanner would otherwise raise the error rate of flows it never touched.

An attributable refusal — one carrying a valid key — also consumes a rate-limit token and, when it is a policy verdict, writes an access.denied entry to the audit chain of the key’s tenant, never the tenant in the path. A refusal with no subject writes no chain entry: without a subject it is a counter, not an attribution, and that is also what stops an anonymous caller from opening a chain on a tenant id of their own invention.

Enforcement today

Keys are required by default. nexus serve closes the gate unless it is started with --no-auth, and then it says so on every boot. POST .../run, POST .../enqueue, POST /connector/events/... and GET /flows/{tenant}/{name} answer 401 without a valid Authorization: Bearer <nxk_...>; GET /health and GET .../wsdl are public by design — a liveness probe carries no credentials, and a WSDL is a discovery contract read by tooling that has none. A public route still checks that the tenant is visible: a disabled or unregistered tenant gets 404 from /wsdl as well.

The gRPC door uses the same gate, with the credential in authorization: Bearer metadata. The one difference is where the tenant comes from: on the HTTP routes it is in the path, on gRPC it comes from the routed flow — a caller names a method, never a tenant.

SOAP has no gate of its own. A request is recognised as SOAP from its body, and the gate finishes before a body exists, so a refusal is a plain HTTP 401/403, not a SOAP Fault — a Fault carries a success status, and a gateway routing on status codes would forward the refusal as an answer. For the same reason a <wsse:UsernameToken> does not authenticate: it is a credential inside the message, and honouring it would mean running an XML parser on unauthenticated input. A flow may check message-level credentials with a validate fence, but that is business authorisation and runs after the decision.

Three consequences worth planning around:

  • An empty key table locks everyone out, which is what it should do. The server warns at boot and names nexus keys create; there is no bootstrap key, because a credential the platform generated would have to be printed somewhere and a live key in a log is worse than a clear error.
  • A storage failure on the authorization path answers 503, never 401 and never a pass. The gate could not decide, and saying “unauthorized” would be a guess.
  • --no-auth is for local development. The two things that need it — examples/demo-scenario1.sh and a nexus simulate run without keys — say so where they use it.

This was not always true: until F34 (ADR-034) nothing in the shipped binary switched the check on, and earlier versions of this page said so. If you are reading a release where nexus serve --help has no --no-auth, that is the version you have.

Shared-secret request signatures

Start the server with --secret and every /run and /enqueue request must carry an X-Nexus-Signature header holding the hex-encoded HMAC-SHA256 of the raw request body, keyed with that secret.

Terminal window
$ nexus serve --port 9090 --secret 'shared-with-the-caller'
NexusFabric server starting on 0.0.0.0:9090
...
webhook signature verification: enabled

Computing it, and sending it:

Terminal window
$ BODY='{"orderId":"A-1","total":42}'
$ SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'shared-with-the-caller' -hex | awk '{print $2}')
$ curl -X POST http://localhost:9090/flows/acme/forward-order/run \
-H "X-Nexus-Signature: $SIG" \
-H 'Content-Type: application/json' \
-d "$BODY"

A missing header, a value that is not hex, or a digest that does not match answers 401. The comparison is constant-time. The signature covers the body only — not the URL, not the other headers, and there is no timestamp in it, so a captured request stays replayable. One secret serves the whole server; it is not per tenant and not per caller.