Skip to content

Run behind a reverse proxy

When a TLS-terminating proxy speaks plain HTTP to the API, the session cookie is only issued Secure if the binary trusts the proxy's forwarded scheme. This is configuration, not a plexctl operation.

Prerequisites

  • A reverse proxy (NGINX, Envoy, Traefik) terminating TLS in front of the API.
  • The proxy able to set X-Forwarded-Proto: https on every forwarded request.

Steps

Enable proxy-header trust on the deployment:

shell
export PLEXSPHERE_AUTH_TRUST_PROXY_HEADERS=true

Pin the forwarded scheme on the proxy — for NGINX:

nginx
proxy_set_header X-Forwarded-Proto $scheme;

The binary then issues the session cookie with Secure, scoped to Path=/v1/ with SameSite=Strict. Serve the SPA from the same public origin so the SameSite=Strict cookie is attached. For local HTTP development, leave PLEXSPHERE_AUTH_TRUST_PROXY_HEADERS unset.

Split the origin so /v1 reaches the API

The session cookie is same-origin by design, so the Console (the static SPA bundle) and the API must answer on one public origin. Split that origin by path: route /v1 — plus the API's root health endpoints (/livez, /readyz) — to the API, and serve everything else from the static bundle with an index.html fallback for unknown paths so the client-side router resolves deep links. For NGINX:

nginx
location /v1/     { proxy_pass http://plexsphere-api; }
location /livez   { proxy_pass http://plexsphere-api; }
location /readyz  { proxy_pass http://plexsphere-api; }

location / {
    root /srv/console;
    try_files $uri /index.html;
}

Do not route /metrics to the public origin — it is unauthenticated. Scrape it over the internal Service/port (or a separate listener), gated by network policy.

The static layer should emit a Content-Security-Policy (including frame-ancestors) as a response header — the Console's index.html carries none. This mirrors the local dev stack, where the Gateway's api listener is split into two HTTPRoutes behind a single origin (see deploy/local/README.md). Keeping the SPA and API on one origin is what lets the Path=/v1/, SameSite=Strict cookie ride every API request.

Rate limiting on the auth surfaces

The unauthenticated auth and bootstrap surfaces — /v1/auth/sign-in, /v1/auth/idp-bindings, /v1/auth/device-code, /v1/auth/device-token, /v1/auth/service/token, and /v1/register — are throttled per client IP by default. A burst past the per-IP budget is answered with 429 Too Many Requests and a Retry-After header before it reaches the expensive work (the Argon2id verification on /v1/register, a Postgres query on /v1/auth/idp-bindings). The throttle sits in front of every authenticator, so no surface can be added behind one and escape it.

POST /v1/keys/rotate is throttled too. It is NSK-authenticated, but the authenticator reads Postgres to resolve the caller's wrapped key before it verifies the envelope, so an unthrottled loop of forged envelopes is the same Postgres flood the list above exists to stop.

Tell the binary where the client IP comes from

The bucket key is the security property here, and there is no safe default for it, so the API refuses to boot with rate limiting enabled until you declare the client-IP source:

  • PLEXSPHERE_AUTH_TRUST_PROXY_HEADERS=true — the API runs behind a proxy (the same flag the cookie logic uses). The limiter reads the caller's address out of X-Forwarded-For.
  • PLEXSPHERE_RATELIMIT_ALLOW_REMOTEADDR=true — the API is directly reachable. The limiter keys on the transport-level peer address and ignores X-Forwarded-For entirely.

Getting this wrong is not a weaker limiter but a broken one. Assume RemoteAddr behind a proxy and every request on earth arrives with the proxy's address, sharing one bucket: eleven anonymous sign-in attempts from anywhere then lock every user out of the platform. Trust the header on a directly-reachable listener and callers pick their own bucket key.

Behind a proxy, the limiter counts hops from the right of X-Forwarded-For. Every proxy that appends to the header — NGINX's $proxy_add_x_forwarded_for, Envoy's append_x_forwarded_for, and so Envoy Gateway — puts the address it observed at the end of the list, so the last element is the one the client cannot forge and the leftmost is whatever the client sent. PLEXSPHERE_RATELIMIT_TRUSTED_PROXY_HOPS declares how many appending proxies stand in front (default 1); with a CDN in front of the gateway, set it to 2. An entry that does not parse as an IP, or a header shorter than the declared chain, falls back to the transport-level peer address.

For NGINX, the append is what you want — do not overwrite the header:

nginx
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

Two optional knobs tune the behaviour:

  • PLEXSPHERE_RATELIMIT_ENABLED=false disables the limiter entirely — an incident kill switch, not a normal setting. It is also the only way to boot without declaring a client-IP source.
  • PLEXSPHERE_RATELIMIT_GENERAL_ENABLED=true also throttles every other /v1 request as a generous DoS backstop. It is off by default because a per-IP cap can surprise legitimate high-throughput clients behind a shared NAT.

The limiter state is per replica and held in memory: each replica caps its own share of a burst independently, which is sufficient for brute-force and CPU-DoS mitigation on the auth surfaces. A distributed (shared) limiter is not part of this deployment model.

Verification

shell
curl -sI https://plexsphere.example/v1/auth/whoami | grep -i set-cookie
# Set-Cookie: … Path=/v1/; Secure; HttpOnly; SameSite=Strict

The cookie must carry Secure and SameSite=Strict.

Confirm the auth surface is throttled — a rapid burst returns 429 with a Retry-After:

shell
for i in $(seq 1 40); do
  curl -s -o /dev/null -w '%{http_code}\n' \
    -X POST https://plexsphere.example/v1/auth/sign-in
done | sort | uniq -c
# expect a mix of 4xx and one or more 429 once the per-IP burst is spent

See also