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
$ nexus keys create --tenant acme --label "order pipeline" --scopes run:orderscreated 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_4f1c8a2e9b7d40a1bd3e6c05f8a91237The 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:
$ 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
$ nexus keys list --tenant acmeid label status flows scopes created_at--------------------------------------------------------------------------------------------------------------3 order pipeline active * run:orders 2026-08-04T11:02:17Z2 partner integration active forward-order run 2026-07-30T08:41:55Z1 old CI key revoked * * 2026-06-12T14:20:09Zstatus is derived: revoked if it was revoked, expired if its expiry has passed, otherwise
active.
$ nexus keys revoke --id 3revoked API key id=3Revocation 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 *.
$ 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-ordertenant: acmerequired_scope: run:orderseffects: [http_egress]---
## Step: forwardeffects: [http_egress]endpoint: https://orders.example.com/ingestmethod: POSTA 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
$ nexus keys create --tenant acme --label "partner integration" \ --scopes run --flows forward-order,order-statusThe 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:
$ curl -i -X POST http://localhost:9090/flows/acme/forward-order/run \ -H 'Authorization: Bearer nxk_...' -d '{}'
HTTP/1.1 429 Too Many RequestsX-RateLimit-Limit: 10X-RateLimit-Remaining: 0X-RateLimit-Reset: 1785060142X-RateLimit-Reset and Retry-After are seconds and Unix seconds respectivelyRetry-After: 1Content-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:
$ 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 acmenexus 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, not413. 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-auththe gate does not run andX-Nexus-Signatureremains 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, never401and never a pass. The gate could not decide, and saying “unauthorized” would be a guess. --no-authis for local development. The two things that need it —examples/demo-scenario1.shand anexus simulaterun 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.
$ nexus serve --port 9090 --secret 'shared-with-the-caller'NexusFabric server starting on 0.0.0.0:9090 ... webhook signature verification: enabledComputing it, and sending it:
$ 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.