The gateway boundary
NexusFabric listens for plain HTTP and plain h2c. It is meant to run behind a reverse proxy, and the two halves divide the work of guarding a request between them. This page draws the line: what the platform decides, what the proxy decides, and what each one needs the other to leave alone.
What the platform decides
Every one of these is configured in the platform and travels with the tenant, the key, the issuer’s
declaration or the flow — so it holds identically on all four doors (/run and the declared routes
of Declared routes, /enqueue, the gRPC door, and a cron tick,
where it applies). The gRPC door reads the same credential through the same
gate; this version’s tests measure OIDC tokens on the HTTP routes only.
| Check | Where it is configured | Refusal |
|---|---|---|
| The caller holds a valid key | nexus keys create, per tenant |
401 |
| The key still exists and has not expired | nexus keys revoke, --expires-at |
401 |
| The caller holds a valid token of the tenant’s declared issuer | --auth-methods on nexus tenant create, then nexus tenant oidc set |
401 |
The client certificate the proxy asserts in Client-Cert is registered for the tenant, and the connection comes from a trusted proxy |
nexus tenant client-cert add, nexus serve --trusted-proxy |
401 |
| The issuer’s signing keys can be refreshed once the cached ones have expired | oidc_jwks_ttl_secs |
503, not 401 |
| The tenant is registered and enabled | nexus tenant create / disable |
404, identical to an unknown flow |
| The key, token or certificate carries the scope the flow demands | --scopes on the key or the certificate subject, --map of the issuer, required_scope: on the flow |
403 |
| The key, token or certificate is allowed to call this flow | --flows on the key, on the issuer’s declaration or on the certificate subject |
403 |
| The caller is inside its request rate | --rate-limit on the key, or per subject on the issuer’s declaration |
429 with Retry-After |
| The body is within the ceiling for this access point | max_message_bytes, three layers |
413 |
| The body carries a valid HMAC signature | NEXUS_WEBHOOK_SECRET |
401 |
Two properties of that list matter when you design the front.
The decision happens before the body is read. The gate runs first, in the same step that resolves
the size ceiling, so an unauthenticated caller announcing a large body is refused at 401 and never
causes a byte of it to be read or parsed. A consequence worth knowing when reading logs: with the
gate on you will not see 413 from an anonymous caller — you will see 401.
A refusal is recorded. Every refusal emits an access_denied event into the structured log, and
a refusal that names a subject — a valid key denied a scope, a flow, or a disabled tenant, or a
valid token denied a scope or a flow — also writes a access.denied entry into that tenant’s audit
chain. Refusals made by the proxy are in the
proxy’s log and nowhere else, which is the main reason to keep the two sets of checks distinct.
Two things do not identify a caller. A credential inside a SOAP message body, such as a
<wsse:UsernameToken>, is not read: SOAP goes through this same gate, which decides before there is
a body. The X-Nexus-Signature of NEXUS_WEBHOOK_SECRET is checked after the key or token and carries no
tenant and no rights, so it does not replace either.
See Authentication for the full behaviour of keys, tokens, scopes, allowlists and rate limits.
What the proxy decides
| Concern | Why it sits in front |
|---|---|
| TLS termination | The platform has no TLS option on the inbound side, by design — one certificate store, one renewal job, one place to configure ciphers |
| Client certificates (mTLS): the TLS handshake | The proxy verifies the certificate chain, its dates and its revocation; the platform reads the certificate the proxy forwards in Client-Cert and identifies the caller by it — see Client certificates |
| Address allowlists | The platform authorises a key, a token or a certificate, not an address — --trusted-proxy only says who may assert a certificate; allow / deny belongs to the listener |
| Per-route and per-path rate limits | The platform’s limit is per key or per OIDC subject, across every route that caller can call — see below |
| Request filtering by method, path or content | A request the proxy drops never reaches a worker |
| Spreading load across engines | Several engines behind one address — see High availability |
Rate limiting, per caller and per route
The platform’s limiter is a token bucket on the key, or on the subject of a token — one sub
of the tenant’s declared issuer: capacity is twice the configured rate, and it is shared by every
route that key or subject is entitled to call. That is the right shape for “this partner may send
twenty messages a second”, and it is the shape that bounds the audit chain, because an attributable
refusal spends a token too.
It cannot express “this path may be called twenty times a second regardless of who calls it”. Write that in the proxy, on the location, where the path is what you have. The two compose: the proxy’s limit shapes the traffic reaching the port, and the platform’s limit holds per partner once it is through.
Identity-provider tokens
Inbound, the platform reads one header, Authorization: Bearer <value>, and validates what it
carries itself: its own key, nxk_…, or an OIDC / OAuth 2.0 token signed by the issuer the tenant
declared. For a token it checks the signature against the issuer’s published keys, the issuer, the
audience, the expiry and the subject — see
OIDC tokens.
So the proxy’s part is to pass Authorization through unchanged. It does not need to validate
the token, and it must not replace it with a key. The one identity header the platform reads from the
proxy is Client-Cert, and only from a trusted proxy; any other, such as X-Forwarded-User, is not
read.
TLS and mTLS still terminate in front. A tenant can also identify its callers by their client certificate, which the proxy forwards in one header — see Client certificates.
Client certificates
The proxy terminates mTLS and forwards the verified certificate to the engine in the Client-Cert
header (on the gRPC door, the client-cert metadata entry). The engine honours it only when the TCP
peer of the connection is in the list given to nexus serve --trusted-proxy, and identifies the
caller by the certificate’s issuer and subject, registered with nexus tenant client-cert add —
see Client certificates. A Client-Cert
from any other peer is answered 401, whatever else the request carries.
The engine cannot tell a Client-Cert the proxy set from one a caller sent through another listener
that forwards it unchanged. So the front has four obligations:
- Every proxy listener that forwards to an engine sets or clears
Client-Cert. In nginx,proxy_set_header Client-Cert $ssl_client_escaped_cert;(grpc_set_headerfor gRPC) on the mTLS listener, andproxy_set_header Client-Cert "";on every other listener. - A trusted entry is trusted for every process behind it.
--trusted-proxy 127.0.0.1on a host that runs anything else makes each of those processes the proxy. Name the proxy’s own address. - The mTLS listener verifies with
ssl_verify_client on, neveroptional_no_ca: the engine does not check the certificate’s chain or dates, it takes the proxy’s word for them. - Revocation is the proxy’s job. Check the CRL or OCSP in front.
nexus tenant client-cert revokewithdraws the registration; it does not revoke the certificate.
The server itself must reach the issuer’s key set over outbound HTTPS. An issuer whose certificate
chains to a private CA is declared with --tls-ca-cert; without it, the built-in public certificate
authorities are trusted. See What the server must reach.
Outbound is the other direction and the platform does it itself: an oauth2_token step performs a
client_credentials exchange and the next step sends the token on. See
Effects.
A worked front
The pieces that matter are the size ceiling, the address lists, the per-location rate limit, and leaving the request otherwise untouched.
limit_req_zone $binary_remote_addr zone=flows:10m rate=20r/s;
upstream nexusfabric { server 127.0.0.1:9090; }
server { listen 443 ssl http2; server_name integrations.example.org;
ssl_certificate /etc/ssl/certs/integrations.pem; ssl_certificate_key /etc/ssl/private/integrations.key;
# At or below the installation's max_message_bytes, so an oversized body # is refused here rather than read twice. client_max_body_size 1m;
location /flows/ { allow 10.0.0.0/8; deny all;
limit_req zone=flows burst=20 nodelay;
proxy_pass http://nexusfabric; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
# The management UI has its own session login. Restrict it separately. location /ui/ { allow 10.1.0.0/16; deny all; proxy_pass http://nexusfabric; }
# Liveness carries no credentials and no body. location = /health { proxy_pass http://nexusfabric; }}The gRPC door is a separate port and speaks h2c. Terminate TLS the same way and hand the plaintext
stream on with grpc_pass:
server { listen 8443 ssl http2; server_name integrations.example.org;
ssl_certificate /etc/ssl/certs/integrations.pem; ssl_certificate_key /etc/ssl/private/integrations.key;
location / { grpc_pass grpc://127.0.0.1:9091; }}Two engines behind one proxy
Two engines on a shared state directory (Running more than one engine)
are one upstream group. Everything above stays; the upstream block and the location blocks change:
upstream nexusfabric { server host-a:9090 max_fails=2 fail_timeout=10s; server host-b:9090 max_fails=2 fail_timeout=10s; keepalive 16;}
# In each location that proxies to the group:proxy_http_version 1.1;proxy_set_header Connection "";proxy_connect_timeout 2s;proxy_next_upstream error timeout http_502 http_503;- Round robin, no
ip_hash, no sticky routing. A management-UI session is stored innexus-ui.db, which both engines share, and every flow call carries its own credential, so any engine can serve any request. Put/ui/on every upstream and forwardHostunchanged. - Passive checks only. Open-source nginx marks an engine down after
max_failsfailures withinfail_timeout.GET /healthis the probe for an external balancer; it answers when the process serves, not when the mount is healthy. - Never add
non_idempotenttoproxy_next_upstream. By default nginx does not retry aPOSTon another engine after it has been sent (RFC 9110 section 9.2.2). A retried/enqueuewould be a second message, and a retried/runa second execution of a flow withoutdedup_key:. Withdedup_key:, while the key is remembered, neither retry runs the flow once an earlier execution of the key finished without failing, or failed underdedup_on_failure: absorb; while that execution still holds its claim on the key,/runanswers409withRetry-Afterand the queue defers the message without spending an attempt; after a failure under the defaultretry, the flow runs again. Leave the retry to the caller, which knows whether the operation is safe to repeat; see Timeouts and retries. - Two limits.
limit_reqin nginx is the installation’s limit. The engine’s own rate-limit bucket is in memory and is the engine’s, so a key can spend up to the number of engines times its rate. - What the proxy still leaves alone is the list below:
Authorization, the path,Host,X-Correlation-IDand the body.
What the proxy must leave alone
Authorization. It is the credential — a key or a token. A proxy that strips or rewrites it turns every request into a401. The proxy does not need to validate a token, and must not replace it with a key.Client-Certis the exception. It is the one header the proxy must always set or clear, as Client certificates says.- The path. The tenant and the flow are path segments, and the platform resolves the artifact
from them. Rewrite the prefix if you must, but keep
/flows/{tenant}/{name}/…intact behind it. Host. The generated WSDL builds its endpoint address from the request’s scheme and host, so a proxy that setsHostcorrectly yields a WSDL a client can use unchanged.X-Correlation-IDandX-Request-ID, when the caller sends one. The platform adopts the first valid one and writes it on the execution’s log events; a flow passes it on by sending{{ ctx.correlation_id }}itself, and a new one per hop makes a trace unreadable. One that is absent or invalid is passed over, and if neither is valid the platform generates one. See Observability.X-Nexus-Signature, whenNEXUS_WEBHOOK_SECRETis set. The signature is over the raw body, so a proxy that re-encodes, decompresses or reformats the body invalidates it.- The management UI’s frame and type headers. Every answer under
/ui/carriesContent-Security-Policy: frame-ancestors 'none',X-Frame-Options: DENYandX-Content-Type-Options: nosniff, and no setting turns them off. The proxy neither adds these headers on/ui/nor overrides them: a second, differentX-Frame-Optionsleaves the browser’s choice undefined, and a proxy policy that allows framing would undo the platform’s. The flow doors and/healthdo not carry them.
Two doors that answer without a key
GET /health and GET /flows/{tenant}/{name}/wsdl are public by design: a liveness probe carries no
credentials, and a WSDL is a discovery contract read by tooling that has none. The WSDL route still
checks that the tenant is visible, so a disabled or unregistered tenant gets the same 404 as an
unknown flow.
If a deployment should not publish its service contracts to the network at large, that is a decision
for the front: keep /health open to the load balancer’s subnet and /wsdl open to the partners who
need it.