Skip to content

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_header for gRPC) on the mTLS listener, and proxy_set_header Client-Cert ""; on every other listener.
  • A trusted entry is trusted for every process behind it. --trusted-proxy 127.0.0.1 on 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, never optional_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 revoke withdraws 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 in nexus-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 forward Host unchanged.
  • Passive checks only. Open-source nginx marks an engine down after max_fails failures within fail_timeout. GET /health is the probe for an external balancer; it answers when the process serves, not when the mount is healthy.
  • Never add non_idempotent to proxy_next_upstream. By default nginx does not retry a POST on another engine after it has been sent (RFC 9110 section 9.2.2). A retried /enqueue would be a second message, and a retried /run a second execution of a flow without dedup_key:. With dedup_key:, while the key is remembered, neither retry runs the flow once an earlier execution of the key finished without failing, or failed under dedup_on_failure: absorb; while that execution still holds its claim on the key, /run answers 409 with Retry-After and the queue defers the message without spending an attempt; after a failure under the default retry, 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_req in 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-ID and 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 a 401. The proxy does not need to validate a token, and must not replace it with a key.
  • Client-Cert is 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 sets Host correctly yields a WSDL a client can use unchanged.
  • X-Correlation-ID and X-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, when NEXUS_WEBHOOK_SECRET is 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/ carries Content-Security-Policy: frame-ancestors 'none', X-Frame-Options: DENY and X-Content-Type-Options: nosniff, and no setting turns them off. The proxy neither adds these headers on /ui/ nor overrides them: a second, different X-Frame-Options leaves the browser’s choice undefined, and a proxy policy that allows framing would undo the platform’s. The flow doors and /health do 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.