Skip to content

Authentication

A caller authenticates with one of three credentials: an API key the platform issues, an OIDC / OAuth 2.0 token signed by the identity provider the tenant declares, or a client certificate verified by the reverse proxy in front and forwarded in the Client-Cert header. The first two travel in the same header, Authorization: Bearer <value>, and a tenant accepts only the methods it was registered with — see The methods a tenant accepts.

This page covers how to create, restrict and revoke keys, how to declare an issuer and map its tokens to scopes, what a flow can demand of a credential, how a client-certificate subject is registered, and how rate limiting behaves. A credential 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 makes a key recognisable in a log or a configuration file, and it tells the server which method to try: a value nxk_… is only ever tried as an API key — see The methods a tenant accepts. 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 rate flows scopes created_at
--------------------------------------------------------------------------------------------------------------------------------------
3 order pipeline active — * run:orders 2026-08-04T11:02:17Z
2 partner integration active 10/s 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.

OIDC tokens

A tenant registered with oidc among its methods accepts tokens signed by one identity provider — its issuer — and the platform verifies each token itself. The caller sends the token in the same header as a key: Authorization: Bearer <token>.

Until the issuer is declared, the tenant admits no token. Every token is answered 401.

Declaring the issuer

Terminal window
$ nexus tenant oidc set --tenant acme \
--issuer https://login.example.com/realms/acme \
--audience nexus-acme \
--jwks-uri https://login.example.com/realms/acme/protocol/openid-connect/certs \
--claim roles --map acme-roles.json --rate-limit 20
declared OIDC issuer of tenant acme
issuer: https://login.example.com/realms/acme
audience: nexus-acme
jwks uri: https://login.example.com/realms/acme/protocol/openid-connect/certs
claim: roles (2 value(s) mapped, 0 default scope(s))
rate limit: 20 req/s per subject
flows: all flows of the tenant

Every flag is described under nexus tenant oidc set. The ones that decide whether a token passes:

  • --issuer is compared exactly with the token’s iss. A trailing slash makes it a different issuer.
  • --audience is the value the token’s aud must contain.
  • --jwks-uri is where the issuer publishes its signing keys. You write it; the platform never discovers it from the issuer, and does not follow a redirect from it.

The declaration follows four rules:

  • One issuer per tenant. Running set again with the same --issuer updates the declaration, and the first word of the output becomes updated. A different issuer is refused, not substituted.
  • An issuer and audience already declared by another tenant are refused, and the refusal names that tenant: a token issued for one tenant would otherwise open the other. Two tenants on the same identity provider need two client registrations there, each with its own audience.
  • A change applies to the next request, with no restart: the server reads the declaration on every request.
  • Each set is recorded as a tenant.oidc_configured entry in the tenant’s audit chain.

There is no list, show or unset beside set, and no page for it in the management UI, in this version.

Mapping a claim to scopes

A token carries its rights in one claim, and --claim names it: roles, groups, scope, or any other name. --map names a JSON file that turns the claim’s values into platform scopes:

{"map": {"meter-reader": ["run:meters"], "auditor": ["run:reports"]}, "default_scopes": []}
  • map lists each claim value and the scopes it grants. A value absent from the map grants nothing; that alone is not a refusal.
  • default_scopes are scopes every valid subject of the issuer gets. Usually empty.
  • The file may also contain "claim": "roles". If it does, it must equal --claim, or set is refused.
  • Unknown keys are refused, so a typo like default_scope is an error, not silently ignored. A scope that is empty or has spaces around it is refused too.

In the token, the claim’s value may be a JSON array of strings, or a single string, which is split on whitespace — so the standard space-separated scope claim works.

The scopes a token gets are then checked exactly like a key’s: against the flow’s required_scope:, and against --flows of the declaration, which restricts the issuer’s subjects to named flows the way --flows restricts a key. A valid token that gets no scope at all is answered 403, and leaves an access.denied entry in the audit chain.

What a token must pass

Check Rule
Signature Verified against the keys fetched from the declared --jwks-uri. Accepted algorithms: RS256, RS384, RS512, ES256, ES384, EdDSA. alg: none and HMAC (HS256 and the like) are refused.
iss Equal to the declared issuer.
aud Contains the declared audience.
exp Present and not past, with 60 s of tolerance for clock skew.
sub Present.

A key named by the token itself — in a jku, x5u or jwk header — is never used.

Any failed check answers 401, with the same body as every other 401. The server’s log records which check failed, with the tenant and the issuer; the token itself is never logged. As with a key, the Authorization header is stripped before the flow sees its headers.

When the issuer is down

The platform caches the issuer’s signing keys, per tenant and issuer, for oidc_jwks_ttl_secs seconds — 300 by default. It asks the issuer again at most once every 30 s per declaration: after a token names a key id it does not know, and after the cache expires.

Issuer Cached keys Caller receives
down younger than the TTL normal answers
down older than the TTL 503 for every token of that issuer, not 401
back up older than the TTL normal answers again within 30 s
  • API keys of the same tenant are not affected by an issuer outage.
  • A token signed with a key the issuer has just published can get 401 for up to 30 s.
  • An issuer that does not answer at all — its packets dropped — can hold a request for up to about 2.25 s, at most once every 30 s per declaration.
  • A longer TTL rides out longer issuer outages. The price is that a key the issuer withdraws is still accepted until the cached keys expire.

oidc_jwks_ttl_secs is not on the settings page. Set it in system_config, the same way as the capacity settings, and restart. If the value is below 30, or is not a plain number, nexus serve refuses to start, naming the key, its value and the floor of 30.

What the server must reach

The server fetches the issuer’s keys from the --jwks-uri over outbound HTTPS. Without --tls-ca-cert the fetch trusts the built-in public certificate authorities. An issuer whose certificate chains to a private CA is declared with --tls-ca-cert <path>: that CA is then the only one trusted for the fetch, and the file is read again at each fetch, so a replaced CA needs no restart. A fetch that fails on the certificate is a failed fetch: the issuer’s tokens are answered 503. The connection times out after 1 s and the whole fetch after 2 s; a key set larger than 256 KiB is refused.

What the platform does not do

  • It does not issue tokens.
  • It does not ask the issuer whether a token is still valid: there is no introspection, and opaque tokens are not accepted.
  • It cannot revoke a token before its exp.

The subject is recorded

A token’s caller is identified by its sub, verbatim, and the platform writes it to three places:

  • the registry database — one row per subject, from the first time one of its tokens passes verification, kept until the database is deleted;
  • the structured log, as subject_label, kept for the log retention;
  • the audit chain, on every 403 of an identified subject — kept forever.

At some issuers sub is an e-mail address or an employee number, which is personal data. Configure the issuer to send an opaque or pairwise sub (OpenID Connect Core 1.0 §8) before you declare it.

Client certificates

A tenant registered with client_cert among its methods identifies a caller by the client certificate the reverse proxy verified. The proxy terminates mTLS and forwards the certificate in one header, Client-Cert; on the gRPC door, in the client-cert metadata entry. The platform does not check the certificate’s chain, dates or revocation: that is the proxy’s job — see The gateway boundary.

The header

Client-Cert holds one certificate, in either of two forms:

  • the RFC 9440 form: : + the standard base64 of the DER + :;
  • URL-encoded PEM, as nginx’s $ssl_client_escaped_cert writes it.

A value over 16 KiB, with a byte outside visible ASCII, in neither form, sent more than once, or not exactly one X.509 certificate is answered 401. The header never reaches a flow: it is not in ctx.headers.

Naming the proxy

The header is honoured only when the TCP peer of the connection is a trusted proxy, named when the server starts:

Terminal window
$ nexus serve --trusted-proxy 10.0.4.17 --trusted-proxy 2001:db8:4::/64

Each value is an IPv4 or IPv6 address or CIDR range; an IPv4 peer reached over IPv6 (::ffff:10.0.4.17) matches the IPv4 entry. A value that does not parse stops the start and is named. Without the flag no peer is trusted, loopback included, and the server writes one WARN naming the tenants that accept client_cert. The peer is the address the connection came from; X-Forwarded-For and every other header are ignored for this.

Registering a subject

The identity is the pair of the certificate’s issuer DN and subject DN. Register it per tenant:

Terminal window
$ nexus tenant client-cert add --tenant acme \
--issuer-dn "CN=Acme Issuing CA,O=Acme SRL,C=RO" \
--subject-dn "CN=orders-gateway,O=Acme SRL,C=RO" \
--flows orders,invoices --scopes run --rate-limit 20 --expires-at 2027-12-31T23:59:59Z
registered client certificate subject id=1

DNs are written in the RFC 4514 string form. Only the types RFC 4514 names (CN, L, ST, O, OU, C, STREET, DC, UID) are written by name; any other type, such as emailAddress or serialNumber, is written as its dotted OID with a # and the hex of its value, and its name is refused. openssl -nameopt RFC2253 prints those types by name, so its output is refused for a certificate that has one. Take the DNs from -nameopt RFC2253,dump_all,oid instead, which works for every certificate:

Terminal window
$ openssl x509 -in client.crt -noout -issuer -subject -nameopt RFC2253,dump_all,oid
issuer=2.5.4.3=#0C0F41636D652049737375696E67204341,2.5.4.10=#0C0841636D652053524C,2.5.4.6=#1302524F
subject=1.2.840.113549.1.9.1=#16106F70734061636D652E6578616D706C65,2.5.4.3=#0C0E6F72646572732D67617465776179,2.5.4.10=#0C0841636D652053524C,2.5.4.6=#1302524F
$ nexus tenant client-cert add --tenant acme \
--issuer-dn "2.5.4.3=#0C0F41636D652049737375696E67204341,2.5.4.10=#0C0841636D652053524C,2.5.4.6=#1302524F" \
--subject-dn "1.2.840.113549.1.9.1=#16106F70734061636D652E6578616D706C65,2.5.4.3=#0C0E6F72646572732D67617465776179,2.5.4.10=#0C0841636D652053524C,2.5.4.6=#1302524F"
registered client certificate subject id=2
tenant: acme
issuer dn: CN=Acme Issuing CA,O=Acme SRL,C=RO
subject dn: 1.2.840.113549.1.9.1=#16106f70734061636d652e6578616d706c65,CN=orders-gateway,O=Acme SRL,C=RO

The command prints the canonical form it stored: the named types as text, the others as #hex. That is the subject emailAddress=ops@acme.example,CN=orders-gateway,O=Acme SRL,C=RO. Attribute types are read in any case and spaces around separators are ignored. Values are compared byte for byte, so CN=Orders and CN=orders are two subjects.

--flows, --scopes, --rate-limit and --expires-at mean what they mean on a key: the flows the subject may call (all, when absent), its scopes (* by default), its token bucket, and the time its registration expires. A zero rate, a past expiry, a DN that does not parse, an unknown tenant and a second registration of a pair that already has one not revoked — expired included — are refused.

Terminal window
$ nexus tenant client-cert list --tenant acme
$ nexus tenant client-cert revoke --tenant acme --id 1

list shows every registration with its state: active, revoked or expired. revoke withdraws the registration; it does not revoke the certificate, which the proxy must refuse through its CRL or OCSP. add and revoke are written to the tenant’s audit chain as client_cert.registered and client_cert.revoked.

What a request gets

Request Answer, HTTP / gRPC
No Client-Cert as the tenant’s next method decides
Client-Cert from a peer not in --trusted-proxy 401 / unauthenticated, whatever else the request carries
From a trusted peer, a value that is not one certificate in one of the two forms 401 / unauthenticated
From a trusted peer, a pair not registered for the tenant, revoked or expired 401 / unauthenticated
A registered, active pair admitted, then flows, scope and rate limit as for a key
A flow outside its --flows, or a scope it lacks 403 / permission_denied
Over its --rate-limit 429 / resource_exhausted

A 401 writes a WARN with the peer address and, when the value decoded, the certificate’s SHA-256 fingerprint and its DNs, and one access_denied event in the structured log; the header value is in neither. A 401 for a tenant whose only method is client_cert carries no WWW-Authenticate; for a tenant that also accepts a Bearer method it carries Bearer. Under --no-auth the HTTP routes skip this method as they skip every other; the gRPC door stays closed.

The subject DN is recorded as the caller’s label: in the structured log, as subject_label, and in the audit chain on registration, revocation and every 403. Log retention clears it from the structured log; the audit chain is append-only and keeps it. A DN that names a person is personal data.

A proxy in front

nginx, verifying the client certificate and forwarding it on HTTP and on gRPC:

server {
listen 443 ssl;
http2 on;
ssl_certificate /etc/nginx/tls/server.crt;
ssl_certificate_key /etc/nginx/tls/server.key;
ssl_client_certificate /etc/nginx/tls/partners-ca.crt;
ssl_verify_client on;
ssl_crl /etc/nginx/tls/partners-ca.crl;
location /flows/ {
proxy_set_header Client-Cert $ssl_client_escaped_cert;
proxy_pass http://10.0.4.20:9090;
}
location /fixture.sample.v1.SampleService/ {
grpc_set_header Client-Cert $ssl_client_escaped_cert;
grpc_pass grpc://10.0.4.20:9091;
}
}

and the engine started with --trusted-proxy set to the proxy’s address. Every other listener of the proxy that forwards to the engine clears the header with proxy_set_header Client-Cert ""; (grpc_set_header on gRPC), so a caller cannot send its own.

Rate limiting

--rate-limit <rps> attaches a token bucket to the key. On an issuer’s declaration (nexus tenant oidc set) it attaches one to each subject of the issuer, that is each sub; there, --unlimited is the explicit choice not to limit.

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 on /run, /enqueue and the gRPC door, and a second one when the request is then refused for lacking the flow’s required_scope. GET /flows/{tenant}/{name} and POST /connector/events/... spend one only on a refusal that names the caller.
Scope Per key, or per OIDC subject. Two keys never share a bucket; two tokens with the same sub share one.

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, which allows a burst of 20 after two seconds of quiet. A key with no limit is never throttled.

A limit exists only where a key or an issuer’s declaration sets one. There is no installation-wide default, no rate ceiling across all callers, and no limit per tenant, per route or per address.

A key’s rate cannot be changed after issue: revoke the key and create another. An issuer’s rate can be changed. A new nexus tenant oidc set with a different rate applies to the subjects’ existing buckets on their next request. A subject keeps its share of the capacity, so lowering the rate gives a drained subject nothing new.

The buckets live in memory. After a restart, every key and every subject gets a new bucket holding one second’s worth: rps tokens, not the full capacity. Each process keeps its own buckets, so two engines behind one address give a key two buckets, each at the full rate.

The limiter runs inside the gate, and nowhere else. The management UI, its login form included, does not pass through it; the login form has its own limit, described in Management UI sign-in. Under --no-auth the HTTP routes skip the gate, so there is no bucket and no counter for them. The gRPC door is the exception: it runs the gate with or without the flag, and the limit with it.

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
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.

The four headers belong to the HTTP routes. On the gRPC door a throttled call gets resource_exhausted, with retry-after in the metadata; see How a flow’s outcome reaches the caller.

A 429 is written to the structured log, not to the audit chain.

Response codes

Code Cause
401 No Authorization header, a header that is not Bearer <value>, or no credential in a form the tenant accepts. A key that is unknown, revoked, expired, or belongs to another tenant. A token that fails any check, or any token of a tenant whose issuer is not declared yet. A Client-Cert refused as What a request gets says.
403 Valid key, token or client certificate, but it lacks the flow’s required_scope, or the flow is outside the --flows of the key or of the issuer’s declaration. A valid token that gets no scope at all.
404 The tenant is not registered, or has been disabled — with exactly the body of a flow that does not exist.
429 Valid key, token or client certificate, over the rate limit of the key, of the token’s subject or of the certificate’s registration.
503 The gate could not reach storage to decide, or the issuer’s signing keys could not be refreshed — see When the issuer is down. Never a pass, and never a 401.

The 401 body does not distinguish between the causes, for a key or for a token.

Every 401 from the key or token check carries one WWW-Authenticate header, as RFC 9110 and RFC 6750 require. The 401 for a missing or wrong X-Nexus-Signature (request signatures) carries none:

Request WWW-Authenticate
No credential in a form the tenant accepts, or an Authorization header that is not Bearer <value> Bearer
A key or token that is not valid: unknown, revoked, expired, another tenant’s, or failing any check Bearer error="invalid_token"

The value is the same whatever the reason the key or token failed, and carries no realm and no description. A refused Client-Cert gets Bearer when the tenant also accepts a Bearer method, and no WWW-Authenticate when client_cert is its only one.

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. Every route with a tenant in its path answers 404 with the body of a flow that does not exist for a tenant that is not registered or not enabled, with or without --no-auth. This includes GET /flows/{t}/{n}/wsdl, even though that route is public: the route still answers without credentials, but only for a tenant that is registered and enabled, and for any other tenant the 404 takes the place of the “no SOAP configuration” answer too. If the tenant registry itself cannot be read, these routes answer 503 with authorization temporarily unavailable under --no-auth as with the gate, and record one access_denied event: a check that cannot be made is never a pass.

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.

While a tenant is disabled, none of its flows runs. A scheduled tick is skipped and is not run later. Its queues keep their messages: undelivered, not dead-lettered, with no attempt counted. A delivery already running when you disable the tenant finishes. enable resumes both without a restart: the next scheduled tick runs, and the queues deliver what they hold, in order, within a few seconds. The retention term keeps running while the tenant is disabled: a message older than its queue_retention_hours when the tenant is enabled goes to the DLQ with reason expired. Each skipped tick, and each queue at the start of its hold, leaves one delivery_refused with reason tenant_disabled in the log.

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 methods a tenant accepts

Terminal window
$ nexus tenant create --id acme --display-name "Acme SRL" --auth-methods api_key,oidc

--auth-methods is an ordered, comma-separated list. Three values are legal: api_key, the default, oidc and client_cert. mtls and anything else are refused at registration. The list cannot be changed after registration — this version has no command for it — so choose it when you create the tenant.

Both methods read the same header, Authorization: Bearer <value>, and the form of the value selects the method:

  • a value nxk_… is only ever tried as an API key;
  • a value shaped like a signed JWT — three base64url segments separated by dots — is only ever tried as an OIDC token.

A method whose form was not presented is skipped. The first method that produces a result decides; a failure is not retried on the next method. A request with no credential in a form the tenant accepts is answered 401.

A tenant with oidc admits no token until its issuer is declared — see Declaring the issuer. A tenant with client_cert admits no certificate until a subject is registered and the server names its proxy — see Client certificates. Methods are tried in the order listed, and the first that decides answers. A Client-Cert refused by client_cert stays refused: a valid key in the same request, for a method listed after it, does not rescue it.

The gate runs before the body

On /run and /enqueue the decision is taken before a single byte of the request body is read. A caller whose key does not resolve causes two indexed queries and nothing else — no registry read, no CAS read, no parsing. A token is checked without the body too; checking it can also cost one request to the issuer for its keys, at most once every 30 s per declaration.

Two consequences:

  • A caller with no valid credential announcing an oversized body gets 401, not 413. The size ceiling is internal platform state and is not disclosed before authentication.
  • The key or token is checked first, the webhook signature second — the credential needs no body, the signature does. With the gate on, a webhook sender already needs a key or a token; 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 structured 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 valid token refused with 403 does the same: it costs its subject a rate-limit token and writes an access.denied entry, carrying the sub, to the audit chain. 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

A credential is 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 <value> — a key or a token, as the tenant’s methods allow — or an accepted Client-Cert from a trusted proxy; 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, or the certificate in client-cert 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. The door reads a key or a token through the same gate; this version’s tests measure OIDC tokens on the HTTP routes only. The --no-auth flag does not open this door: the gate runs there either way. A start with both --no-auth and --grpc-port writes one warning saying so: the HTTP routes are open, and every call on the gRPC port still needs an API key (nexus seed prints one).

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 out every caller that relies on a key, which is what it should do. When no key and no issuer exist at all, the server warns at boot and names nexus keys create and nexus tenant oidc set; 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 one thing that needs it, examples/demo-scenario1.sh, says so where it uses 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

Set NEXUS_WEBHOOK_SECRET in the server’s environment 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 value. Put it in the service’s EnvironmentFile or .env, not in the command: an argument is readable by every user of the host in the process list. Set and empty, or not valid UTF-8, the server refuses to start.

With the systemd unit of Installing, whose EnvironmentFile=/etc/nexus/nexus.env hands the file to the process:

Terminal window
$ sudo grep NEXUS_WEBHOOK_SECRET /etc/nexus/nexus.env
NEXUS_WEBHOOK_SECRET=shared-with-the-caller
$ sudo systemctl restart nexus
$ journalctl -u nexus -b -o cat | grep 'webhook signature'
webhook signature verification: enabled

The binary itself never opens /etc/nexus/nexus.env: systemd reads it. Started by hand, the server takes the variable from its own environment or from a .env in its working directory (see .env for when that file is read).

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.

Management UI sign-in

A sign-in opens a session held in one cookie, nxsession, which carries Secure together with HttpOnly and SameSite=Strict. No flag or setting removes Secure. A browser sends a Secure cookie only over a channel it considers secure, so where sign-in works depends on how the UI is reached:

  • Through the TLS front (The gateway boundary), sign-in works.
  • Over plain HTTP at localhost or 127.0.0.1, it works only in a browser that keeps Secure cookies there, such as Chrome.
  • Over plain HTTP at any other address, it worked in no browser tested.

Where the browser drops the cookie, the login form still answers with a redirect, and the next page sends you back to the login form, with no message.

The UI cannot be shown inside a frame, on any site, its own included; see What the proxy must leave alone.

The management UI’s login form, POST /ui/login, limits failed attempts on its own. It does not use the API-key limiter, and --no-auth does not change it.

  • Per handle, whatever the address. Each engine counts failed sign-ins per submitted handle, the user name cut to its first 64 characters. The address an attempt comes from plays no part, so spreading attempts over many addresses does not reset the count. A handle that matches no account is counted exactly like one that does.
  • The pause. The 5th failure in a row on a handle pauses it for 1 minute. Each further failure doubles the pause, to 2, 4 and 8 minutes, then 15 minutes for every failure after that. An attempt during a pause is refused without checking the password, even when the password is right. It is not counted and does not lengthen the pause.
  • The answer. A paused attempt gets the login page with status 429 and Retry-After, the whole seconds left in the pause. The page says that sign-in is paused after repeated failed attempts, with no number, and does not say whether the handle exists. A wrong password outside a pause gets 401 and the generic message.
  • The same cost for every refusal. A handle that matches no account, or a disabled account, costs the same password check as a wrong password, so the time of the answer does not tell which handles exist.
  • What clears a handle. A successful sign-in clears its count. A handle with no failure for 12 hours is forgotten. The counts live in memory: a restart clears them all, and each engine keeps its own, so two engines over one state directory give a handle twice the allowance.
  • Bounds. The table holds at most 10,000 handles. When it is full, the handle with the oldest failure among those that match no account makes room, so a flood of invented handles does not clear a real account’s count.

Every refused sign-in writes one WARN line to the server log, message UI login refused, with three fields:

WARN nexus_ui::handlers::auth: UI login refused handle="admin" peer=203.0.113.7 reason=wrong_password
Field Value
handle The submitted handle, its first 64 characters, in quotes, with control characters escaped (a line feed is written \n), so one refusal is always one line.
peer The IP address of the TCP connection. Behind a reverse proxy it is the proxy’s address.
reason wrong_password, unknown_handle, account_disabled, or locked for an attempt during a pause.

The submitted password is written nowhere. The line goes to the server log only, not to the audit chain and not to L1.

A UI password has at least 15 characters, counted as characters, not bytes. The user form, the profile page, nexus seed and the first account from NEXUS_UI_ADMIN_PASS all apply the same minimum; see The first UI account.

A password change ends every other session of the account and replaces the current cookie. It is made on the profile page. Every other browser or device signed in to that account, including one holding a copy of your cookie, is sent back to the login form on its next request. The session you changed it from keeps working on the new cookie, which expires when the old one would have; the page you land on carries its new form token, so a form left open in another tab from before the change is refused. A sign-in that checked the old password while the change was being made opens a session that is refused on its first request. The profile page lists the ended sessions under Ended sessions, with the time each one ended, until they would have expired. A refused change (wrong current password, too short, or the two new passwords differ) ends no session.