Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TLS and reverse proxies

An identity provider must be reached over https: browsers will not keep rIDM’s __Host- cookies otherwise, and relying parties validate the issuer URL, which carries the scheme. rIDM can terminate TLS itself or sit behind a proxy that does. Either way, four settings have to agree with how clients actually reach it: PUBLIC_URL, UI_URL, TRUSTED_PROXIES and COOKIE_SECURE.

PUBLIC_URL and UI_URL

PUBLIC_URL is the externally visible base URL, for example https://id.example.com. It is required, must be http or https, and rIDM derives everything client-facing from it:

  • every tenant’s issuer is {PUBLIC_URL}/t/{slug} (https://id.example.com/t/acme), and so are the endpoint URLs in its discovery document;
  • Strict-Transport-Security is sent only when it is https;
  • its origin is admitted for cross-origin calls from the consoles.

rIDM does not look at X-Forwarded-Proto or the request’s own scheme. If the proxy terminates TLS and forwards plain http, PUBLIC_URL still says https://, and that is what clients see. A PUBLIC_URL that does not match what clients type produces tokens whose iss no relying party accepts, so set it before registering any client and treat changing it as a migration for every relying party.

UI_URL is where the sign-in pages and consoles are served. It defaults to PUBLIC_URL, which is right when rIDM serves its embedded UI (the default, and the layout the snippets below use) or when the proxy serves ui/out on the same host. Set it only when the UI lives elsewhere; see Deployment overview.

Every node must have the same PUBLIC_URL and UI_URL.

Option 1: TLS at a proxy or load balancer

The usual layout. The proxy holds the certificate, speaks https to clients and plain http to rIDM on a private network. Leave TLS_CERT and TLS_KEY unset.

The proxy must:

  • pass the original Host header (rIDM matches custom domains by it, or by X-Forwarded-Host from a trusted proxy);
  • append the client address to X-Forwarded-For (or send Forwarded: for=...);
  • have its address, as rIDM sees it, in TRUSTED_PROXIES;
  • allow request bodies of 32 MiB on /admin/, which is what bulk user import accepts (nginx’s default is 1 MiB).

Option 2: native TLS

Set both TLS_CERT (a PEM certificate chain) and TLS_KEY (the PEM private key) and the listener on BIND_ADDR speaks https only; there is no plain-http listener beside it and no redirect from http. Setting one without the other is a configuration error. The files are read once at startup, so a renewed certificate takes a restart (a rolling restart across nodes is enough).

Native TLS suits a single node or a layer-4 load balancer that passes TCP through. With no proxy in front, TRUSTED_PROXIES stays empty and the TCP peer is the client. The image’s --healthcheck probe follows the listener: with TLS_CERT set it speaks https and trusts exactly that certificate (see Container image).

Client certificates (mutual TLS)

Clients that authenticate with a TLS certificate (RFC 8705) need whatever terminates TLS to ask for one. Either run rIDM’s own mTLS listener on a second port (MTLS_BIND), or have the proxy request the certificate on a separate host and forward it in a header (CLIENT_CERT_HEADER), which rIDM believes only from TRUSTED_PROXIES. Keep that host apart from the one users sign in on (MTLS_PUBLIC_URL), so browsers never show a certificate picker. nginx, Caddy and Traefik snippets are in Mutual TLS.

TRUSTED_PROXIES and the client address

The client address feeds IP rules, every per-address rate limit, login-attempt records, session metadata and the audit log. rIDM takes it from the TCP peer unless the peer is listed in TRUSTED_PROXIES, a comma-separated list of CIDRs or single addresses:

TRUSTED_PROXIES=10.0.0.0/8,192.168.10.5

When the peer is trusted, rIDM reads X-Forwarded-For (or, if that is absent, Forwarded’s for= values) from the right, skipping every address that is itself in TRUSTED_PROXIES, and takes the first one that is not. Proxies that append (nginx’s $proxy_add_x_forwarded_for, AWS load balancers) leave whatever the caller sent at the left; reading from the right means a caller cannot choose the address rIDM records. An entry that does not parse as an address ends the walk.

Getting it wrong has a direction:

SettingEffect
Empty behind a proxyEvery request appears to come from the proxy. Per-address rate limits then count all users together and IP rules match the proxy
Too wide (for example 0.0.0.0/0)Anyone who can reach rIDM directly can forge their address with a header, defeating IP rules and per-address limits
Exactly the proxy tierCorrect

List every hop between the internet and rIDM that you operate: a CDN in front of a load balancer in front of nginx means all three ranges. X-Forwarded-Host is honoured under the same rule, and only matters for custom domains.

Cookies

rIDM sets two cookies, both host-only (no Domain), Path=/, HttpOnly and SameSite=Lax. Each carries the tenant’s slug in its name, so tenants on one host never share them; for tenant acme:

COOKIE_SECURESession cookieTrusted-device cookieAttributes
true (default)__Host-ridm_session_acme__Host-ridm_device_acmeSecure
falseridm_session_acmeridm_device_acmenone extra

The name, not the path, separates tenants because a __Host- cookie must have Path=/, and because a custom domain serves the tenant without the /t/{slug} prefix. Deployments that ran an earlier build, whose cookies were named without the slug and scoped to /t/{slug}, sign every user out once on upgrade, and remembered devices are forgotten once.

Browsers accept a Secure cookie, and anything named __Host-, only from a secure origin. So with the default COOKIE_SECURE=true, sign-in works only when the browser reaches rIDM over https. COOKIE_SECURE=false is for plain-http local development and nothing else. The compose dev profile sets it; the prod profile does not.

SameSite=Lax also means the cookie is not sent on cross-site fetch calls, which is why the UI belongs on the same origin as the API (or at least the same site).

Proxy configurations

The repository ships configurations for three proxies in deploy/proxy/. They are what the production compose stack runs, and CI boots that stack behind each one and checks it from outside: the http redirect, the https issuer, rIDM’s headers arriving unchanged, /metrics refused, a forged X-Forwarded-For ignored, a 20 MiB bulk-import body arriving whole, and a custom domain served as its tenant.

All three assume the default layout: the UI embedded in the server, so every path goes to the rIDM nodes. They pass Host through, give rIDM the connecting address in X-Forwarded-For, allow the 32 MiB bulk import, refuse /metrics and add no security headers: rIDM sends its own on every response, pages included (framing is refused everywhere except the login page, which only its own origin may frame, for the console’s branding preview), and HSTS when PUBLIC_URL is https. /docs is served only if DOCS_ENABLED is on, which production should not do.

nginx

deploy/proxy/nginx/templates/default.conf.template is a template for the official image, which fills in RIDM_DOMAIN, RIDM_CUSTOM_DOMAINS and RIDM_UPSTREAM at start. Its core, with the values filled in for nodes outside Docker:

upstream ridm {
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
    keepalive 32;
}

server {
    listen 443 ssl;
    http2 on;
    server_name id.example.com;

    ssl_certificate     /etc/nginx/tls/fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/privkey.pem;

    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    location / {
        proxy_pass http://ridm;
    }
    # Bulk user import accepts up to 32 MiB; nginx's default is 1 MiB.
    location /admin/ {
        client_max_body_size 32m;
        proxy_pass http://ridm;
    }
    # Scrape /metrics on the private network instead.
    location = /metrics { return 404; }
}

The file also redirects http to https, refuses the TLS handshake for hosts it does not serve, and re-resolves the upstream through Docker’s DNS (resolve, nginx 1.27.3 or later), which outside Docker becomes your own resolver or a fixed list of addresses. nginx obtains no certificates: renew them with your ACME client and reload.

$proxy_add_x_forwarded_for appends to whatever the caller sent, which is safe because rIDM reads the list from the right (see above). Setting it to $remote_addr instead is equally correct at the edge but loses the earlier hops when nginx sits behind another proxy you operate.

Caddy

deploy/proxy/caddy/Caddyfile obtains and renews certificates itself (or reads files with RIDM_TLS_MODE=files). Caddy passes Host through and replaces X-Forwarded-For with the connecting address by default, and has no request-body limit, so the whole configuration is short:

id.example.com login.acme.com {
	respond /metrics 404
	reverse_proxy 10.0.1.11:8080 10.0.1.12:8080 {
		health_uri /readyz
		health_interval 10s
	}
	header -Server
}

Traefik

Traefik takes its static configuration (entry points, the http-to-https redirect, the Let’s Encrypt resolver) from one source only; the compose stack gives it as the traefik service’s command:. The routes are the file provider’s deploy/proxy/traefik/dynamic/ridm.yml, a Go template reading the same RIDM_ variables: one router for every host, a /metrics router behind an ipAllowList that admits nobody, and a load-balanced service with /readyz health checks. Traefik drops a caller’s X-Forwarded-For unless the caller is in the entry point’s forwardedHeaders.trustedIPs (none are configured) and sets it to the connecting address; it has no request-body limit.

With Traefik’s Docker provider instead of a file, the same router is a set of labels on the rIDM service:

labels:
  traefik.enable: "true"
  traefik.http.routers.ridm.rule: Host(`id.example.com`)
  traefik.http.routers.ridm.entrypoints: websecure
  traefik.http.routers.ridm.tls.certresolver: letsencrypt
  traefik.http.services.ridm.loadbalancer.server.port: "8080"
  traefik.http.services.ridm.loadbalancer.healthcheck.path: /readyz

Hosting ui/out yourself

A binary built without the embedded UI serves only the API, and the proxy serves the static export. Route the API paths to rIDM and serve the files, adding the headers a page’s <meta> policy cannot set. The console frames /login/ for its branding preview, so that one page must allow its own origin. This variant is not among the tested configurations; in nginx:

    location ~ ^/(t|admin|scim|\.well-known)/ { proxy_pass http://ridm; ... }
    location ~ ^/(openapi\.json|healthz|readyz)$ { proxy_pass http://ridm; ... }

    root /srv/ridm/ui/out;
    location / {
        try_files $uri $uri/ =404;
        add_header X-Frame-Options "DENY" always;
        add_header Content-Security-Policy "frame-ancestors 'none'" always;
        add_header Strict-Transport-Security "max-age=63072000" always;
        add_header X-Content-Type-Options "nosniff" always;
    }
    location = /login/ {
        try_files /login/index.html =404;
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header Content-Security-Policy "frame-ancestors 'self'" always;
        add_header Strict-Transport-Security "max-age=63072000" always;
        add_header X-Content-Type-Options "nosniff" always;
    }
    error_page 404 /404.html;

The repository’s OpenID conformance setup puts Caddy in front of rIDM in this layout, API paths to the server and the rest to ui/out (conformance/Caddyfile).

Custom domains behind the proxy

A tenant with a custom domain (say login.acme.com) is served on that host without the /t/{slug} prefix, so the proxy must send that host to rIDM with Host preserved and hold a certificate for it: in the shipped configurations, add it to RIDM_CUSTOM_DOMAINS. rIDM compares the whole Host value, port included, with the domain, so serve custom domains on port 443. With the embedded UI the tenant’s sign-in pages and account console are served on that host as well, through the same route. See Custom domains.