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.

Set the OIDC callback URL to the public origin:

shell
export PLEXSPHERE_AUTH_CALLBACK_URL=https://<public-host>/v1/auth/callback

The API sends this URL to the IdP as the redirect_uri of every sign-in, and it is the redirect URI to register there. Unset, the API derives the URL from each request, and that derivation reads the scheme off its own connection, not off X-Forwarded-Proto, whatever PLEXSPHERE_AUTH_TRUST_PROXY_HEADERS says. Behind the proxy it yields http://, and the IdP refuses the sign-in because that redirect URI is not registered. A Helm install sets the variable from auth.callbackUrl, or from externalUrl when that key is empty; see the Helm chart reference.

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 and /downloads 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 /downloads/ { proxy_pass http://plexsphere-api; }

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

/downloads is the one public path outside /v1: a machine the provisioning broker boots fetches the plexd binary and its checksums from /downloads/plexd without credentials. A cold API replica fetches the binary from GitHub before it answers its first request for a platform, so give /downloads/ a proxy read timeout of a few minutes rather than the default 60 seconds. The other endpoints the API serves outside /v1 stay in-cluster. /metrics is unauthenticated, so the public origin must never carry it; scrape it over the internal Service or port from a collector a network policy admits. /livez and /readyz answer for one Pod rather than for the origin, so probe them the same way, over the internal Service or through a port-forward.

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 ​