From bd14f2a5c600ff49fe2eb548269c2d9c5c4ef3b1 Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Tue, 16 Jun 2026 10:44:58 -0400
Subject: [PATCH 01/20] Replace builtin proxy auth with oauth2-proxy

Rather than expecting manager-specific semantics for the nginx
auth_request flow, use oauth2-proxy as the expected forward-auth API.
Administrators run and configure their own oauth2-proxy server; the
manager no longer provides forward-auth itself.

Template (views/nginx-conf.ejs):
- Add /oauth2/ and internal /oauth2/auth locations that proxy to the
  configured oauth2-proxy upstream (the domain's authServer).
- Use `auth_request /oauth2/auth` with `error_page 401 = @oauth2_signin`,
  redirecting (302) to the same-host /oauth2/sign_in path.
- Capture oauth2-proxy's X-Auth-Request-* response headers but forward
  them to the backend under the stable X-User / X-Email / X-Groups /
  X-Access-Token contract.

Manager forward-auth removal:
- Delete routers/verify.js and its mount + SPA catch-all exception.
- Scope the manager session cookie to its own host (drop the
  cross-subdomain cookie sharing that only existed for auth_request).

The authServer field now points at the oauth2-proxy upstream
(e.g. http://127.0.0.1:4180); model comment, form/list UI, and docs
updated accordingly.

Refs #348
---
 .../ExternalDomainFormPage.tsx                |  5 +-
 .../ExternalDomainsListPage.tsx               |  2 +-
 create-a-container/models/external-domain.js  |  2 +-
 create-a-container/routers/verify.js          | 61 -------------
 create-a-container/server.js                  | 19 ++--
 create-a-container/views/nginx-conf.ejs       | 90 ++++++++++++-------
 error-pages/auth-unavailable.html             |  8 +-
 .../docs/admins/core-concepts/containers.md   |  2 +-
 .../admins/core-concepts/external-domains.md  | 59 ++++++------
 .../docs/admins/installation.md               |  2 +-
 .../docs/developers/database-schema.md        |  6 +-
 .../docs/developers/system-architecture.md    | 16 ++--
 12 files changed, 114 insertions(+), 158 deletions(-)
 delete mode 100644 create-a-container/routers/verify.js

diff --git a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx
index df44ae3e..f83c74df 100644
--- a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx
+++ b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx
@@ -166,8 +166,9 @@ export function ExternalDomainFormPage() {
           {...register('cloudflareApiKey')}
         />
         <Input
-          label="Auth server"
-          helperText="Optional URL for nginx auth_request"
+          label="oauth2-proxy URL"
+          placeholder="http://127.0.0.1:4180"
+          helperText="Optional. Upstream URL of an oauth2-proxy server used for nginx auth_request."
           {...register('authServer')}
         />
         {mutation.error && (
diff --git a/create-a-container/client/src/pages/external-domains/ExternalDomainsListPage.tsx b/create-a-container/client/src/pages/external-domains/ExternalDomainsListPage.tsx
index 36b58321..8b97d794 100644
--- a/create-a-container/client/src/pages/external-domains/ExternalDomainsListPage.tsx
+++ b/create-a-container/client/src/pages/external-domains/ExternalDomainsListPage.tsx
@@ -67,7 +67,7 @@ export function ExternalDomainsListPage() {
               <TableHead>Domain</TableHead>
               <TableHead>Site</TableHead>
               <TableHead>Cloudflare</TableHead>
-              <TableHead>Auth server</TableHead>
+              <TableHead>oauth2-proxy</TableHead>
               <TableHead className="text-right">Actions</TableHead>
             </TableRow>
           </TableHeader>
diff --git a/create-a-container/models/external-domain.js b/create-a-container/models/external-domain.js
index ea24c340..47fa52ba 100644
--- a/create-a-container/models/external-domain.js
+++ b/create-a-container/models/external-domain.js
@@ -59,7 +59,7 @@ module.exports = (sequelize) => {
       validate: {
         isUrl: true
       },
-      comment: 'Auth server URL for auth_request (e.g., https://manager.example.com). Must implement GET /verify and GET /login?redirect='
+      comment: 'oauth2-proxy upstream URL for nginx auth_request (e.g., http://127.0.0.1:4180). The administrator must run an oauth2-proxy server configured for this domain.'
     }
   }, {
     tableName: 'ExternalDomains',
diff --git a/create-a-container/routers/verify.js b/create-a-container/routers/verify.js
deleted file mode 100644
index 4775da88..00000000
--- a/create-a-container/routers/verify.js
+++ /dev/null
@@ -1,61 +0,0 @@
-const express = require('express');
-const { ApiKey, User, Group } = require('../models');
-const { extractKeyPrefix } = require('../utils/apikey');
-
-const router = express.Router();
-
-function setUserHeaders(res, user, groups) {
-  res.set('X-User-ID', String(user.uidNumber));
-  res.set('X-Username', user.uid);
-  res.set('X-User-First-Name', user.givenName);
-  res.set('X-User-Last-Name', user.sn);
-  res.set('X-Email', user.mail);
-  res.set('X-Groups', groups.map((g) => g.cn).join(','));
-}
-
-// GET /verify — lightweight auth check for nginx auth_request subrequests.
-// Returns 200 with user identity headers if authenticated, 401 otherwise.
-router.get('/', async (req, res) => {
-  if (req.session && req.session.user) {
-    const user = await User.findOne({
-      where: { uid: req.session.user },
-      include: [{ model: Group, as: 'groups' }],
-    });
-    if (user) {
-      setUserHeaders(res, user, user.groups || []);
-      return res.status(200).send();
-    }
-  }
-
-  const authHeader = req.get('Authorization');
-  if (authHeader && authHeader.startsWith('Bearer ')) {
-    const apiKey = authHeader.substring(7);
-    if (apiKey) {
-      const keyPrefix = extractKeyPrefix(apiKey);
-      const apiKeys = await ApiKey.findAll({
-        where: { keyPrefix },
-        include: [{
-          model: User,
-          as: 'user',
-          include: [{ model: Group, as: 'groups' }],
-        }],
-      });
-      for (const storedKey of apiKeys) {
-        const isValid = await storedKey.validateKey(apiKey);
-        if (isValid) {
-          storedKey.recordUsage().catch((err) => {
-            console.error('Failed to update API key last used timestamp:', err);
-          });
-          if (storedKey.user) {
-            setUserHeaders(res, storedKey.user, storedKey.user.groups || []);
-          }
-          return res.status(200).send();
-        }
-      }
-    }
-  }
-
-  return res.status(401).send();
-});
-
-module.exports = router;
diff --git a/create-a-container/server.js b/create-a-container/server.js
index 650384b3..73726e01 100644
--- a/create-a-container/server.js
+++ b/create-a-container/server.js
@@ -8,7 +8,6 @@ const SequelizeStore = require('express-session-sequelize')(session.Store);
 const path = require('path');
 const RateLimit = require('express-rate-limit');
 const crypto = require('crypto');
-const net = require('net');
 const swaggerUi = require('swagger-ui-express');
 const YAML = require('yamljs');
 const { sequelize, SessionSecret } = require('./models');
@@ -58,25 +57,19 @@ async function main() {
     store: sessionStore,
     resave: false,
     saveUninitialized: false,
-    // Dynamic cookie: drop the host part and set domain to the parent domain
-    // (e.g., manager.example.com → .example.com) so the session cookie is
-    // shared across sibling subdomains for nginx auth_request.
-    // For IP addresses (IPv4/IPv6) and single-label hosts like "localhost",
-    // omit the domain attribute so the browser scopes the cookie to the
-    // exact host (RFC 6265 forbids domain attributes on IP literals).
+    // The manager's session cookie only needs to be valid for the manager
+    // host itself — forward-auth for other subdomains is handled by an
+    // external oauth2-proxy server, which manages its own cookies. We leave
+    // the cookie scoped to the exact host (no `domain` attribute).
     // `secure` is derived from the request protocol (honoring `trust proxy`
     // and X-Forwarded-Proto from nginx) rather than NODE_ENV, so the flag
     // tracks the actual transport — set on HTTPS, omitted on plain HTTP
     // bootstrap/dev access.
     cookie: function(req) {
-      const hostname = req.hostname || '';
-      const parts = hostname.split('.');
-      const shouldDropHost = !net.isIP(hostname) && parts.length > 2;
       return {
         secure: req.secure,
         maxAge: 24 * 60 * 60 * 1000, // 24 hours
         sameSite: 'lax',
-        domain: shouldDropHost ? `.${parts.slice(1).join('.')}` : hostname
       };
     }
   }));
@@ -103,10 +96,8 @@ async function main() {
   // --- Mount Routers ---
   const apiV1Router = require('./routers/api/v1');
   const templatesRouter = require('./routers/templates');
-  const verifyRouter = require('./routers/verify');
 
   app.use('/api/v1', apiV1Router);
-  app.use('/verify', verifyRouter); // nginx auth_request subrequest endpoint
   app.use('/', templatesRouter); // serves /sites/:siteId/nginx and /sites/:siteId/dnsmasq/:file
 
   // --- API Documentation (Swagger UI) ---
@@ -123,7 +114,7 @@ async function main() {
   // --- SPA: serve compiled React app for everything else ---
   const clientDist = path.join(__dirname, 'client', 'dist');
   app.use(express.static(clientDist));
-  app.get(/^\/(?!api(\/|$)|verify(\/|$)|sites\/[^/]+\/(nginx$|dnsmasq\/)).*$/, (req, res) => {
+  app.get(/^\/(?!api(\/|$)|sites\/[^/]+\/(nginx$|dnsmasq\/)).*$/, (req, res) => {
     res.sendFile(path.join(clientDist, 'index.html'));
   });
 
diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs
index a638e4fe..d5bdbec9 100644
--- a/create-a-container/views/nginx-conf.ejs
+++ b/create-a-container/views/nginx-conf.ejs
@@ -237,42 +237,64 @@ http {
     }
 
     <%_ if (authRequired && authServer) { _%>
-    # Auth subrequest — proxied to the auth server's /verify endpoint.
-    # Responses are cached per Cookie+Authorization pair so NGINX only
-    # contacts the auth server when credentials change.
-    location = /.oss-auth-verify {
+    # --- oauth2-proxy integration -------------------------------------------
+    # The administrator runs an oauth2-proxy server (configured separately) and
+    # sets this domain's "oauth2-proxy URL" to its upstream address. NGINX
+    # authenticates each request against oauth2-proxy's /oauth2/auth endpoint
+    # using the auth_request module.
+    # See https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/
+    set $oauth2_proxy <%= authServer %>;
+
+    # Sign-in, callback, and other oauth2-proxy endpoints.
+    location /oauth2/ {
+      resolver 127.0.0.1;
+      proxy_pass $oauth2_proxy;
+      proxy_set_header Host                    $host;
+      proxy_set_header X-Real-IP               $remote_addr;
+      proxy_set_header X-Scheme                $scheme;
+      proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri;
+    }
+
+    # Internal auth check. oauth2-proxy returns 202 (authenticated) or 401.
+    # Responses are cached per Cookie+Authorization pair so NGINX only contacts
+    # oauth2-proxy when the credentials change.
+    location = /oauth2/auth {
       internal;
       resolver 127.0.0.1;
-      set $auth_server <%= authServer %>;
-      proxy_pass $auth_server/verify;
-      proxy_pass_request_body off;
-      proxy_set_header Content-Length "";
-      proxy_set_header X-Original-URI $request_uri;
-      proxy_set_header X-Original-Host $host;
-      proxy_set_header Cookie $http_cookie;
-      proxy_set_header Authorization $http_authorization;
+      proxy_pass $oauth2_proxy;
+      proxy_set_header Host             $host;
+      proxy_set_header X-Real-IP        $remote_addr;
+      proxy_set_header X-Scheme         $scheme;
+      # nginx auth_request includes headers but not the request body.
+      proxy_set_header Content-Length   "";
+      proxy_pass_request_body           off;
 
       proxy_cache auth_cache;
       proxy_cache_key "$http_cookie$http_authorization";
-      proxy_cache_valid 200 5m;
+      proxy_cache_valid 202 5m;
       proxy_cache_valid 401 30s;
     }
 
-    # Capture user identity headers from the auth subrequest response.
-    auth_request_set $auth_user_id $upstream_http_x_user_id;
-    auth_request_set $auth_username $upstream_http_x_username;
-    auth_request_set $auth_first_name $upstream_http_x_user_first_name;
-    auth_request_set $auth_last_name $upstream_http_x_user_last_name;
-    auth_request_set $auth_email $upstream_http_x_email;
-    auth_request_set $auth_groups $upstream_http_x_groups;
-
-    location @login_redirect {
-      return 302 <%= authServer %>/login?redirect=https://$host$request_uri;
+    # Named location that issues a proper 302 redirect to oauth2-proxy's
+    # sign-in page when auth_request returns 401. The sign-in page is served
+    # on this same host via the `location /oauth2/` proxy above, so redirect
+    # to the local path rather than the (internal) upstream address.
+    location @oauth2_signin {
+      return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri;
     }
 
+    # Capture identity from the auth_request response. Requires oauth2-proxy to
+    # run with --set-xauthrequest (and --pass-access-token for the access token).
+    # oauth2-proxy returns these as X-Auth-Request-* headers; we forward them to
+    # the backend under the stable X-User / X-Email contract (see below).
+    auth_request_set $auth_user  $upstream_http_x_auth_request_user;
+    auth_request_set $auth_email $upstream_http_x_auth_request_email;
+    auth_request_set $auth_groups $upstream_http_x_auth_request_groups;
+    auth_request_set $auth_token $upstream_http_x_auth_request_access_token;
+
     <%_ } _%>
     <%_ if (authRequired && !authServer) { _%>
-    # Authentication required but no auth server URL configured for this domain
+    # Authentication required but no oauth2-proxy URL configured for this domain
     location @auth_unavailable {
       rewrite ^ /auth-unavailable.html break;
       proxy_method GET;
@@ -289,16 +311,16 @@ http {
     # Proxy settings
     location / {
       <%_ if (authRequired && authServer) { _%>
-      auth_request /.oss-auth-verify;
-      error_page 401 = @login_redirect;
-
-      # Forward user identity from auth subrequest to backend
-      proxy_set_header X-User-ID $auth_user_id;
-      proxy_set_header X-Username $auth_username;
-      proxy_set_header X-User-First-Name $auth_first_name;
-      proxy_set_header X-User-Last-Name $auth_last_name;
-      proxy_set_header X-Email $auth_email;
-      proxy_set_header X-Groups $auth_groups;
+      auth_request /oauth2/auth;
+      error_page 401 = @oauth2_signin;
+
+      # Forward identity to the backend under a stable header contract.
+      # (oauth2-proxy's own X-Auth-Request-* names are mapped here so the
+      # backend contract stays the same regardless of the auth provider.)
+      proxy_set_header X-User         $auth_user;
+      proxy_set_header X-Email        $auth_email;
+      proxy_set_header X-Groups       $auth_groups;
+      proxy_set_header X-Access-Token $auth_token;
       <%_ } _%>
       proxy_pass <%= service.httpService.backendProtocol %>://<%= service.Container.ipv4Address %>:<%= service.internalPort %>;
       proxy_http_version 1.1;
diff --git a/error-pages/auth-unavailable.html b/error-pages/auth-unavailable.html
index d9592442..aa379cd3 100644
--- a/error-pages/auth-unavailable.html
+++ b/error-pages/auth-unavailable.html
@@ -115,22 +115,22 @@
     <div class="status-code">503</div>
     <h1>Authentication Unavailable</h1>
     <p>
-      This service requires authentication, but no authentication server has been
+      This service requires authentication, but no oauth2-proxy server has been
       configured for this domain.
     </p>
     <div class="hostname" id="hostname"></div>
     <div class="info">
       <h2>What happened?</h2>
       <p>
-        This service has authentication enabled, but no authentication server
+        This service has authentication enabled, but no oauth2-proxy server
         has been configured for the domain. To resolve this:
       </p>
       <ul>
         <li>If you're a <strong>user</strong> of this service, let the person in charge of it know.</li>
         <li>If you're in <strong>charge of this service</strong> and authentication was enabled
           by mistake, disable it. Otherwise, contact your environment administrator.</li>
-        <li>If you're the <strong>environment administrator</strong>, configure an auth server
-          for this domain.</li>
+        <li>If you're the <strong>environment administrator</strong>, configure an oauth2-proxy
+          server for this domain.</li>
       </ul>
     </div>
   </div>
diff --git a/mie-opensource-landing/docs/admins/core-concepts/containers.md b/mie-opensource-landing/docs/admins/core-concepts/containers.md
index 851b8488..04eceec8 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/containers.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/containers.md
@@ -20,5 +20,5 @@ Users in the **ldapusers** group can SSH into any container using their cluster
 
 Users can expose HTTP services from containers using [external domains](external-domains.md). Services are automatically configured with SSL/TLS certificates, reverse proxy routing, and DNS records.
 
-HTTP services can optionally require authentication via the **Require auth** checkbox. When enabled, NGINX authenticates requests against the domain's [auth server](external-domains.md#authentication) before proxying. Authenticated requests include identity headers (`X-User-ID`, `X-Username`, etc.) forwarded to the backend. See [External Domains — Authentication](external-domains.md#authentication) for configuration details.
+HTTP services can optionally require authentication via the **Require auth** checkbox. When enabled, NGINX authenticates requests against the domain's [oauth2-proxy server](external-domains.md#authentication) before proxying. Authenticated requests include identity headers (`X-User`, `X-Email`, `X-Groups`) forwarded to the backend. See [External Domains — Authentication](external-domains.md#authentication) for configuration details.
 
diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index 256d753a..61eb2ebc 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -18,7 +18,7 @@ External domains expose container HTTP services to the internet. Domains are glo
 | **ACME Directory** | CA endpoint, Let's Encrypt Production/Staging (currently unused) |
 | **Cloudflare API Email** | Cloudflare account email, sent as the `X-Auth-Email` header — optional unless using Cross-Site DNS |
 | **Cloudflare API Key** | Cloudflare **User API Token**, sent as `Authorization: Bearer <token>`. Despite the field name, this is *not* the legacy Global API Key. Optional unless using Cross-Site DNS. |
-| **Auth Server URL** | Optional — URL of an authentication server for NGINX `auth_request`. See [Authentication](#authentication) |
+| **oauth2-proxy URL** | Optional — upstream URL of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server for NGINX `auth_request`. See [Authentication](#authentication) |
 
 !!! tip
     Use Let's Encrypt **Staging** for testing — it has higher rate limits. Switch to **Production** once verified.
@@ -111,35 +111,36 @@ When creating a container service, users select an external domain and specify a
 
 ## Authentication
 
-HTTP services can require authentication via NGINX's [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module. When a service has **Require auth** enabled, NGINX sends a subrequest to the domain's auth server before proxying each request. Unauthenticated users are redirected to the auth server's login page.
+HTTP services can require authentication via NGINX's [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module, delegated to an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server that you run and configure. When a service has **Require auth** enabled, NGINX authenticates each request against oauth2-proxy's `/oauth2/auth` endpoint before proxying. Unauthenticated users are redirected to oauth2-proxy's sign-in page.
 
-### Auth Server Requirements
+The manager does **not** provide authentication itself — you must deploy and configure a valid oauth2-proxy server (with your chosen OIDC/OAuth2 provider) and point the domain's **oauth2-proxy URL** at it.
 
-The auth server URL (e.g., `https://manager.example.com`) must implement two endpoints:
+### Configuring oauth2-proxy
 
-| Endpoint | Behavior |
-|----------|----------|
-| `GET /verify` | Return `2xx` if the user is authenticated, `401` otherwise. May return identity headers (see below). |
-| `GET /login?redirect=<url>` | Login page that redirects to `<url>` after successful authentication. |
+Set the domain's **oauth2-proxy URL** to the upstream address of your oauth2-proxy instance (e.g. `http://127.0.0.1:4180`). NGINX proxies the `/oauth2/` paths on each authenticated service's host to this upstream, so the OAuth2 endpoints are served on the same hostname as the application.
 
-The manager application implements both endpoints and can be used as the auth server.
+Run oauth2-proxy with at least:
+
+- `--reverse-proxy=true` — required when behind NGINX.
+- `--set-xauthrequest=true` — so identity is returned in `X-Auth-Request-*` response headers (forwarded to the backend).
+- `--pass-access-token=true` *(optional)* — to forward the access token as `X-Access-Token`.
+
+See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/) for full configuration details.
 
 ### Identity Headers
 
-On successful authentication, the auth server can return identity headers that NGINX forwards to the backend:
+When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Request-*` response headers and forwards them to the backend under a **stable header contract** (so the backend sees the same names regardless of the auth provider):
 
-| Header | Description |
-|--------|-------------|
-| `X-User-ID` | Numeric user ID |
-| `X-Username` | Username |
-| `X-User-First-Name` | First name |
-| `X-User-Last-Name` | Last name |
-| `X-Email` | Email address |
-| `X-Groups` | Comma-separated group names |
+| Header forwarded to backend | Source (oauth2-proxy response) |
+|-----------------------------|--------------------------------|
+| `X-User` | `X-Auth-Request-User` |
+| `X-Email` | `X-Auth-Request-Email` |
+| `X-Groups` | `X-Auth-Request-Groups` |
+| `X-Access-Token` | `X-Auth-Request-Access-Token` (with `--pass-access-token`) |
 
-### Cookie Sharing
+### Cookie Domain
 
-The auth server must be on a subdomain of the external domain (e.g., `manager.example.com` for domain `example.com`). The manager sets its session cookie on the parent domain (`.example.com`) so sibling subdomains share the cookie for `auth_request` subrequests.
+Because oauth2-proxy is served on the same hostname as each application (via the `/oauth2/` proxy), its session cookie is scoped to that host by default. To share a single sign-in session across multiple subdomains of the external domain, configure oauth2-proxy with a parent `--cookie-domain` (e.g. `.example.com`) and a `--whitelist-domain` for the redirect targets.
 
 ### Flow
 
@@ -147,19 +148,21 @@ The auth server must be on a subdomain of the external domain (e.g., `manager.ex
 sequenceDiagram
     participant Client
     participant NGINX
-    participant AuthServer as Auth Server
+    participant OAuth2Proxy as oauth2-proxy
     participant Backend
 
     Client->>NGINX: GET app.example.com/page
-    NGINX->>AuthServer: GET /verify (subrequest)
-    alt Authenticated
-        AuthServer-->>NGINX: 200 + identity headers
-        NGINX->>Backend: Proxied request + X-User-* headers
+    NGINX->>OAuth2Proxy: auth_request → GET /oauth2/auth (subrequest)
+    alt 202 (authenticated)
+        OAuth2Proxy-->>NGINX: 202 + X-Auth-Request-* headers
+        NGINX->>Backend: Proxied request + identity headers
         Backend-->>NGINX: Response
         NGINX-->>Client: Response
-    else Not authenticated
-        AuthServer-->>NGINX: 401
-        NGINX-->>Client: 302 → auth server /login?redirect=...
+    else 401 (unauthenticated)
+        OAuth2Proxy-->>NGINX: 401
+        NGINX-->>Client: 302 → /oauth2/sign_in?rd=original_url
     end
 ```
 
+If **Require auth** is enabled but no **oauth2-proxy URL** is configured on the domain, NGINX serves a 503 "Authentication Unavailable" page.
+
diff --git a/mie-opensource-landing/docs/admins/installation.md b/mie-opensource-landing/docs/admins/installation.md
index 39f802f9..bbb499c9 100644
--- a/mie-opensource-landing/docs/admins/installation.md
+++ b/mie-opensource-landing/docs/admins/installation.md
@@ -104,7 +104,7 @@ Further reading: [External Domains](core-concepts/external-domains.md).
     2. **Default Site**: `First Site`
     3. **ACME Email** and **ACME Directory** are currently unused.
     4. **Cloudflare API Email** and **Key** are optional unless you are planning to use Cross-Site DNS.
-    5. **Auth Server URL**: `https://manager.example.org` (see [Authentication](core-concepts/external-domains.md#authentication)).
+    5. **oauth2-proxy URL**: optional — the upstream address of an oauth2-proxy server (e.g. `http://127.0.0.1:4180`) if you want to require authentication for services on this domain (see [Authentication](core-concepts/external-domains.md#authentication)).
 3. Select "Create External Domain".
 4. Refer to [SSL Certificate Provisioning](core-concepts/external-domains.md#ssl-certificate-provisioning) to configure an HTTPS certificate.
 
diff --git a/mie-opensource-landing/docs/developers/database-schema.md b/mie-opensource-landing/docs/developers/database-schema.md
index 0b792dd5..2742e102 100644
--- a/mie-opensource-landing/docs/developers/database-schema.md
+++ b/mie-opensource-landing/docs/developers/database-schema.md
@@ -105,7 +105,7 @@ erDiagram
         string cloudflareApiEmail
         string cloudflareApiKey
         int siteId FK "nullable, default site"
-        string authServer "nullable, auth server URL"
+        string authServer "nullable, oauth2-proxy upstream URL"
     }
 
     Jobs {
@@ -185,12 +185,12 @@ LXC container on a Proxmox node. Unique composite index on `(nodeId, containerId
 ### Service (STI)
 Base model with `type` discriminator (`http`, `transport`, `dns`). Belongs to Container.
 
-- **HTTPService**: `(externalHostname, externalDomainId)` unique. Belongs to ExternalDomain. `backendProtocol` controls `proxy_pass` scheme (`http` or `https`). `authRequired` enables NGINX `auth_request` — requires the domain's `authServer` to be configured.
+- **HTTPService**: `(externalHostname, externalDomainId)` unique. Belongs to ExternalDomain. `backendProtocol` controls `proxy_pass` scheme (`http` or `https`). `authRequired` enables NGINX `auth_request` against the domain's oauth2-proxy — requires the domain's `authServer` to be configured.
 - **TransportService**: `(protocol, externalPort)` unique. `findNextAvailablePort()` static method.
 - **DnsService**: SRV records with `serviceName`.
 
 ### ExternalDomain
-Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional URL pointing to an authentication server that implements the NGINX `auth_request` protocol (see [External Domains](../admins/core-concepts/external-domains.md#authentication)).
+Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional URL pointing to an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) upstream used for NGINX `auth_request` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)).
 
 ## User Management Models
 
diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md
index 57b99c83..24f48316 100644
--- a/mie-opensource-landing/docs/developers/system-architecture.md
+++ b/mie-opensource-landing/docs/developers/system-architecture.md
@@ -146,29 +146,29 @@ sequenceDiagram
 
 ### Authenticated HTTP Services
 
-When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests before proxying. The domain's `authServer` must be configured (see [External Domains](../admins/core-concepts/external-domains.md#authentication)).
+When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests against an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server before proxying. The domain's `authServer` must point to the oauth2-proxy upstream (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). The manager itself no longer provides forward-auth — administrators run and configure oauth2-proxy.
 
 ```mermaid
 sequenceDiagram
     participant Client
     participant NGINX
-    participant AuthServer as Auth Server
+    participant OAuth2Proxy as oauth2-proxy
     participant Container
 
     Client->>NGINX: GET app.example.com/page
-    NGINX->>AuthServer: Subrequest: GET /verify
-    alt 2xx (authenticated)
-        AuthServer-->>NGINX: 200 + X-User-* headers
+    NGINX->>OAuth2Proxy: Subrequest: GET /oauth2/auth
+    alt 202 (authenticated)
+        OAuth2Proxy-->>NGINX: 202 + X-Auth-Request-* headers
         NGINX->>Container: Proxied request + identity headers
         Container-->>NGINX: Response
         NGINX-->>Client: Response
     else 401 (unauthenticated)
-        AuthServer-->>NGINX: 401
-        NGINX-->>Client: 302 → /login?redirect=original_url
+        OAuth2Proxy-->>NGINX: 401
+        NGINX-->>Client: 302 → /oauth2/sign_in?rd=original_url
     end
 ```
 
-NGINX captures identity headers from the auth server subrequest (`X-User-ID`, `X-Username`, `X-User-First-Name`, `X-User-Last-Name`, `X-Email`, `X-Groups`) and forwards them to the backend container via `proxy_set_header`.
+NGINX captures identity headers from the oauth2-proxy subrequest (`X-Auth-Request-User`, `X-Auth-Request-Email`, `X-Auth-Request-Groups`, and the access token) and forwards them to the backend container via `proxy_set_header` under a stable contract (`X-User`, `X-Email`, `X-Groups`, `X-Access-Token`). This requires oauth2-proxy to run with `--set-xauthrequest` (and `--pass-access-token` for the token).
 
 If `authRequired` is enabled but no `authServer` is configured on the domain, NGINX serves a 503 error page.
 

From bfbf47b483ee9fdc8ac454bfa2fffedf9bfc397d Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Tue, 16 Jun 2026 11:16:22 -0400
Subject: [PATCH 02/20] oauth2-proxy auth_request: support central auth host on
 the same LB

The auth server (oauth2-proxy) is expected to be a routable host on the
same load balancer (e.g. https://oauth2-proxy.example.com) that many
apps delegate to, rather than a per-app loopback upstream.

When NGINX proxies the /oauth2/auth subrequest to that host over the
load balancer, it now pins `Host` to the auth host's own name
(parsed from authServer via `new URL().host`) instead of `$host`.
Sending `$host` (the app's hostname) would make the proxied request
re-match the app's own server block and loop through auth_request
indefinitely; pinning Host routes it to the oauth2-proxy server block.

Other changes:
- @oauth2_signin now 302s the browser to the auth host's
  /oauth2/sign_in?rd=<absolute app url>; X-Auth-Request-Redirect uses
  the absolute $scheme://$host$request_uri (multi-domain form) so one
  oauth2-proxy serves many app hosts.
- proxy_ssl_server_name on so SNI matches the pinned Host on the HTTPS
  hop; drop the now-unused resolver (proxy_pass target is a literal).
- Guard against a malformed authServer (unparseable URL) by falling
  back to the 503 "auth unavailable" page instead of emitting a broken
  proxy_pass.
- authServer now documents/represents the public oauth2-proxy URL;
  updated model comment, form helper/placeholder, and docs (incl.
  --cookie-domain/--whitelist-domain guidance, the separate
  oauth2-proxy server-block requirement, and a loop-protection note).

Refs #348
---
 .../ExternalDomainFormPage.tsx                |  4 +-
 create-a-container/models/external-domain.js  |  2 +-
 create-a-container/views/nginx-conf.ejs       | 75 +++++++++++--------
 .../admins/core-concepts/external-domains.md  | 25 +++++--
 .../docs/admins/installation.md               |  2 +-
 .../docs/developers/database-schema.md        |  4 +-
 .../docs/developers/system-architecture.md    | 12 +--
 7 files changed, 75 insertions(+), 49 deletions(-)

diff --git a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx
index f83c74df..6c26b6a0 100644
--- a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx
+++ b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx
@@ -167,8 +167,8 @@ export function ExternalDomainFormPage() {
         />
         <Input
           label="oauth2-proxy URL"
-          placeholder="http://127.0.0.1:4180"
-          helperText="Optional. Upstream URL of an oauth2-proxy server used for nginx auth_request."
+          placeholder="https://oauth2-proxy.example.com"
+          helperText="Optional. Public URL of an oauth2-proxy server (a routable host on this load balancer) used for nginx auth_request."
           {...register('authServer')}
         />
         {mutation.error && (
diff --git a/create-a-container/models/external-domain.js b/create-a-container/models/external-domain.js
index 47fa52ba..681b057c 100644
--- a/create-a-container/models/external-domain.js
+++ b/create-a-container/models/external-domain.js
@@ -59,7 +59,7 @@ module.exports = (sequelize) => {
       validate: {
         isUrl: true
       },
-      comment: 'oauth2-proxy upstream URL for nginx auth_request (e.g., http://127.0.0.1:4180). The administrator must run an oauth2-proxy server configured for this domain.'
+      comment: 'Public oauth2-proxy URL for nginx auth_request (e.g., https://oauth2-proxy.example.com). A routable host on the same load balancer that proxies to an administrator-run oauth2-proxy; protected services delegate auth to it.'
     }
   }, {
     tableName: 'ExternalDomains',
diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs
index d5bdbec9..8e8cb768 100644
--- a/create-a-container/views/nginx-conf.ejs
+++ b/create-a-container/views/nginx-conf.ejs
@@ -185,6 +185,20 @@ http {
   <%_ httpServices.forEach((service, index) => { _%>
   <%_ const authRequired = service.httpService.authRequired; _%>
   <%_ const authServer = service.httpService.externalDomain.authServer; _%>
+  <%_
+    // The auth server (oauth2-proxy) is a routable host on this same load
+    // balancer (e.g. https://oauth2-proxy.example.com). When NGINX proxies the
+    // /oauth2/* paths to it, it must send Host = the auth host's own name — NOT
+    // $host — otherwise the proxied request would re-match THIS app's server
+    // block and loop through auth_request. Parse the host out of authServer so
+    // we can pin the upstream Host header. Falls back to disabling auth if the
+    // URL is unparseable.
+    let authHost = null;
+    if (authRequired && authServer) {
+      try { authHost = new URL(authServer).host; } catch (e) { authHost = null; }
+    }
+    const authEnabled = authRequired && authServer && authHost;
+  _%>
   server {
     listen 443 ssl;
     listen [::]:443 ssl;
@@ -236,38 +250,37 @@ http {
       proxy_pass http://error_pages;
     }
 
-    <%_ if (authRequired && authServer) { _%>
+    <%_ if (authEnabled) { _%>
     # --- oauth2-proxy integration -------------------------------------------
-    # The administrator runs an oauth2-proxy server (configured separately) and
-    # sets this domain's "oauth2-proxy URL" to its upstream address. NGINX
-    # authenticates each request against oauth2-proxy's /oauth2/auth endpoint
-    # using the auth_request module.
+    # The administrator runs a single oauth2-proxy, published as a routable host
+    # on this same load balancer (the domain's "oauth2-proxy URL", e.g.
+    # https://oauth2-proxy.example.com — configured as its own external-domain
+    # service that proxies to the oauth2-proxy process). Every authenticated app
+    # on the load balancer delegates to it via auth_request.
+    #
+    # IMPORTANT: the subrequest below proxies to the auth host over the load
+    # balancer, so it MUST send `Host: <%= authHost %>` (the auth host's own
+    # name) and NOT `$host`. With `$host`, the proxied request would re-match
+    # THIS app's server block and loop through auth_request indefinitely.
     # See https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/
-    set $oauth2_proxy <%= authServer %>;
-
-    # Sign-in, callback, and other oauth2-proxy endpoints.
-    location /oauth2/ {
-      resolver 127.0.0.1;
-      proxy_pass $oauth2_proxy;
-      proxy_set_header Host                    $host;
-      proxy_set_header X-Real-IP               $remote_addr;
-      proxy_set_header X-Scheme                $scheme;
-      proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri;
-    }
 
     # Internal auth check. oauth2-proxy returns 202 (authenticated) or 401.
     # Responses are cached per Cookie+Authorization pair so NGINX only contacts
     # oauth2-proxy when the credentials change.
     location = /oauth2/auth {
       internal;
-      resolver 127.0.0.1;
-      proxy_pass $oauth2_proxy;
-      proxy_set_header Host             $host;
-      proxy_set_header X-Real-IP        $remote_addr;
-      proxy_set_header X-Scheme         $scheme;
+      proxy_pass <%= authServer %>/oauth2/auth;
+      # Pin Host to the auth host (loop guard — see note above).
+      proxy_set_header Host                    <%= authHost %>;
+      proxy_ssl_server_name on;
+      proxy_set_header X-Real-IP               $remote_addr;
+      proxy_set_header X-Scheme                $scheme;
+      # Preserve the ORIGINAL app URL so oauth2-proxy returns the user here
+      # after sign-in (absolute form is required for the multi-domain setup).
+      proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri;
       # nginx auth_request includes headers but not the request body.
-      proxy_set_header Content-Length   "";
-      proxy_pass_request_body           off;
+      proxy_set_header Content-Length          "";
+      proxy_pass_request_body                  off;
 
       proxy_cache auth_cache;
       proxy_cache_key "$http_cookie$http_authorization";
@@ -275,12 +288,12 @@ http {
       proxy_cache_valid 401 30s;
     }
 
-    # Named location that issues a proper 302 redirect to oauth2-proxy's
-    # sign-in page when auth_request returns 401. The sign-in page is served
-    # on this same host via the `location /oauth2/` proxy above, so redirect
-    # to the local path rather than the (internal) upstream address.
+    # On 401, send the browser to oauth2-proxy's sign-in page on the auth host.
+    # The browser authenticates there, the session cookie is set on the shared
+    # parent domain (oauth2-proxy --cookie-domain=.<domain>), and `rd` brings the
+    # user back to this app afterward.
     location @oauth2_signin {
-      return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri;
+      return 302 <%= authServer %>/oauth2/sign_in?rd=$scheme://$host$request_uri;
     }
 
     # Capture identity from the auth_request response. Requires oauth2-proxy to
@@ -293,8 +306,8 @@ http {
     auth_request_set $auth_token $upstream_http_x_auth_request_access_token;
 
     <%_ } _%>
-    <%_ if (authRequired && !authServer) { _%>
-    # Authentication required but no oauth2-proxy URL configured for this domain
+    <%_ if (authRequired && !authEnabled) { _%>
+    # Authentication required but no (valid) oauth2-proxy URL configured for this domain
     location @auth_unavailable {
       rewrite ^ /auth-unavailable.html break;
       proxy_method GET;
@@ -310,7 +323,7 @@ http {
     <%_ } else { _%>
     # Proxy settings
     location / {
-      <%_ if (authRequired && authServer) { _%>
+      <%_ if (authEnabled) { _%>
       auth_request /oauth2/auth;
       error_page 401 = @oauth2_signin;
 
diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index 61eb2ebc..8041788b 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -18,7 +18,7 @@ External domains expose container HTTP services to the internet. Domains are glo
 | **ACME Directory** | CA endpoint, Let's Encrypt Production/Staging (currently unused) |
 | **Cloudflare API Email** | Cloudflare account email, sent as the `X-Auth-Email` header — optional unless using Cross-Site DNS |
 | **Cloudflare API Key** | Cloudflare **User API Token**, sent as `Authorization: Bearer <token>`. Despite the field name, this is *not* the legacy Global API Key. Optional unless using Cross-Site DNS. |
-| **oauth2-proxy URL** | Optional — upstream URL of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server for NGINX `auth_request`. See [Authentication](#authentication) |
+| **oauth2-proxy URL** | Optional — public URL of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server (a routable host on this load balancer, e.g. `https://oauth2-proxy.example.com`) for NGINX `auth_request`. See [Authentication](#authentication) |
 
 !!! tip
     Use Let's Encrypt **Staging** for testing — it has higher rate limits. Switch to **Production** once verified.
@@ -117,16 +117,27 @@ The manager does **not** provide authentication itself — you must deploy and c
 
 ### Configuring oauth2-proxy
 
-Set the domain's **oauth2-proxy URL** to the upstream address of your oauth2-proxy instance (e.g. `http://127.0.0.1:4180`). NGINX proxies the `/oauth2/` paths on each authenticated service's host to this upstream, so the OAuth2 endpoints are served on the same hostname as the application.
+A **single** oauth2-proxy instance can authenticate every service on the load balancer. Publish it as its own routable host (e.g. `oauth2-proxy.example.com`) and set the domain's **oauth2-proxy URL** to that public URL (e.g. `https://oauth2-proxy.example.com`).
+
+1. **Expose oauth2-proxy as its own service.** Create an HTTP service (e.g. hostname `oauth2-proxy` on `example.com`) that proxies to your oauth2-proxy process, and leave **Require auth disabled** on it — it *is* the auth server, so it must never require auth itself (doing so would make it call `auth_request` against itself and loop).
+2. **Point protected domains at it.** Set the **oauth2-proxy URL** on each external domain whose services should require auth to `https://oauth2-proxy.example.com`.
+3. **Enable Require auth** on the individual services you want protected.
+
+When a protected service (e.g. `app.example.com`) receives a request, NGINX issues an `auth_request` subrequest to `https://oauth2-proxy.example.com/oauth2/auth`. On `401`, the browser is redirected to `https://oauth2-proxy.example.com/oauth2/sign_in?rd=<original-url>`; after sign-in the user is sent back to the originating service.
 
 Run oauth2-proxy with at least:
 
 - `--reverse-proxy=true` — required when behind NGINX.
 - `--set-xauthrequest=true` — so identity is returned in `X-Auth-Request-*` response headers (forwarded to the backend).
+- `--cookie-domain=.example.com` — so the session cookie is shared across all sibling subdomains (required for one oauth2-proxy to serve multiple hosts).
+- `--whitelist-domain=.example.com` — so post-sign-in redirects back to your app hosts are allowed.
 - `--pass-access-token=true` *(optional)* — to forward the access token as `X-Access-Token`.
 
 See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/) for full configuration details.
 
+!!! warning "Loop protection"
+    When NGINX proxies the auth subrequest to `oauth2-proxy.example.com` over the same load balancer, it sends `Host: oauth2-proxy.example.com` (the auth host's own name) — **not** the app's host. This is what makes the request land on the oauth2-proxy server block instead of re-matching the app's block and looping through `auth_request`. The generated config does this automatically; just make sure the **oauth2-proxy URL** is the auth server's own public hostname and that **Require auth is off** on the oauth2-proxy service.
+
 ### Identity Headers
 
 When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Request-*` response headers and forwards them to the backend under a **stable header contract** (so the backend sees the same names regardless of the auth provider):
@@ -140,7 +151,7 @@ When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Req
 
 ### Cookie Domain
 
-Because oauth2-proxy is served on the same hostname as each application (via the `/oauth2/` proxy), its session cookie is scoped to that host by default. To share a single sign-in session across multiple subdomains of the external domain, configure oauth2-proxy with a parent `--cookie-domain` (e.g. `.example.com`) and a `--whitelist-domain` for the redirect targets.
+A single oauth2-proxy serving multiple hosts must set its session cookie on the shared parent domain so every app subdomain can present it on the `auth_request` subrequest. Configure oauth2-proxy with `--cookie-domain=.example.com` and `--whitelist-domain=.example.com`. The auth server and all protected services must therefore be subdomains of the same parent domain.
 
 ### Flow
 
@@ -148,11 +159,11 @@ Because oauth2-proxy is served on the same hostname as each application (via the
 sequenceDiagram
     participant Client
     participant NGINX
-    participant OAuth2Proxy as oauth2-proxy
+    participant OAuth2Proxy as oauth2-proxy.example.com
     participant Backend
 
     Client->>NGINX: GET app.example.com/page
-    NGINX->>OAuth2Proxy: auth_request → GET /oauth2/auth (subrequest)
+    NGINX->>OAuth2Proxy: auth_request → GET /oauth2/auth (Host: oauth2-proxy.example.com)
     alt 202 (authenticated)
         OAuth2Proxy-->>NGINX: 202 + X-Auth-Request-* headers
         NGINX->>Backend: Proxied request + identity headers
@@ -160,9 +171,9 @@ sequenceDiagram
         NGINX-->>Client: Response
     else 401 (unauthenticated)
         OAuth2Proxy-->>NGINX: 401
-        NGINX-->>Client: 302 → /oauth2/sign_in?rd=original_url
+        NGINX-->>Client: 302 → https://oauth2-proxy.example.com/oauth2/sign_in?rd=https://app.example.com/page
     end
 ```
 
-If **Require auth** is enabled but no **oauth2-proxy URL** is configured on the domain, NGINX serves a 503 "Authentication Unavailable" page.
+If **Require auth** is enabled but no (valid) **oauth2-proxy URL** is configured on the domain, NGINX serves a 503 "Authentication Unavailable" page.
 
diff --git a/mie-opensource-landing/docs/admins/installation.md b/mie-opensource-landing/docs/admins/installation.md
index bbb499c9..588460d7 100644
--- a/mie-opensource-landing/docs/admins/installation.md
+++ b/mie-opensource-landing/docs/admins/installation.md
@@ -104,7 +104,7 @@ Further reading: [External Domains](core-concepts/external-domains.md).
     2. **Default Site**: `First Site`
     3. **ACME Email** and **ACME Directory** are currently unused.
     4. **Cloudflare API Email** and **Key** are optional unless you are planning to use Cross-Site DNS.
-    5. **oauth2-proxy URL**: optional — the upstream address of an oauth2-proxy server (e.g. `http://127.0.0.1:4180`) if you want to require authentication for services on this domain (see [Authentication](core-concepts/external-domains.md#authentication)).
+    5. **oauth2-proxy URL**: optional — the public URL of an oauth2-proxy server (a routable host on this load balancer, e.g. `https://oauth2-proxy.example.org`) if you want to require authentication for services on this domain (see [Authentication](core-concepts/external-domains.md#authentication)).
 3. Select "Create External Domain".
 4. Refer to [SSL Certificate Provisioning](core-concepts/external-domains.md#ssl-certificate-provisioning) to configure an HTTPS certificate.
 
diff --git a/mie-opensource-landing/docs/developers/database-schema.md b/mie-opensource-landing/docs/developers/database-schema.md
index 2742e102..ab1f3f24 100644
--- a/mie-opensource-landing/docs/developers/database-schema.md
+++ b/mie-opensource-landing/docs/developers/database-schema.md
@@ -105,7 +105,7 @@ erDiagram
         string cloudflareApiEmail
         string cloudflareApiKey
         int siteId FK "nullable, default site"
-        string authServer "nullable, oauth2-proxy upstream URL"
+        string authServer "nullable, public oauth2-proxy URL"
     }
 
     Jobs {
@@ -190,7 +190,7 @@ Base model with `type` discriminator (`http`, `transport`, `dns`). Belongs to Co
 - **DnsService**: SRV records with `serviceName`.
 
 ### ExternalDomain
-Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional URL pointing to an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) upstream used for NGINX `auth_request` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)).
+Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional public URL of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) host (a routable host on the same load balancer) used for NGINX `auth_request` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)).
 
 ## User Management Models
 
diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md
index 24f48316..d71f2405 100644
--- a/mie-opensource-landing/docs/developers/system-architecture.md
+++ b/mie-opensource-landing/docs/developers/system-architecture.md
@@ -146,17 +146,19 @@ sequenceDiagram
 
 ### Authenticated HTTP Services
 
-When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests against an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server before proxying. The domain's `authServer` must point to the oauth2-proxy upstream (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). The manager itself no longer provides forward-auth — administrators run and configure oauth2-proxy.
+When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests against an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server before proxying. The domain's `authServer` is the oauth2-proxy server's public URL — a routable host on the same load balancer, e.g. `https://oauth2-proxy.example.com` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). The manager itself no longer provides forward-auth — administrators run and configure oauth2-proxy.
+
+The auth subrequest is proxied to that host **with `Host` pinned to the auth host's own name** (`new URL(authServer).host`), not the app's `$host`. Because the subrequest travels back over the load balancer, using `$host` would re-match the app's own `server` block and loop through `auth_request`; pinning `Host` makes it land on the oauth2-proxy `server` block instead. The redirect target (`X-Auth-Request-Redirect` / `rd=`) uses the absolute `$scheme://$host$request_uri` so one oauth2-proxy can serve many app hosts.
 
 ```mermaid
 sequenceDiagram
     participant Client
     participant NGINX
-    participant OAuth2Proxy as oauth2-proxy
+    participant OAuth2Proxy as oauth2-proxy.example.com
     participant Container
 
     Client->>NGINX: GET app.example.com/page
-    NGINX->>OAuth2Proxy: Subrequest: GET /oauth2/auth
+    NGINX->>OAuth2Proxy: Subrequest: GET /oauth2/auth (Host: oauth2-proxy.example.com)
     alt 202 (authenticated)
         OAuth2Proxy-->>NGINX: 202 + X-Auth-Request-* headers
         NGINX->>Container: Proxied request + identity headers
@@ -164,11 +166,11 @@ sequenceDiagram
         NGINX-->>Client: Response
     else 401 (unauthenticated)
         OAuth2Proxy-->>NGINX: 401
-        NGINX-->>Client: 302 → /oauth2/sign_in?rd=original_url
+        NGINX-->>Client: 302 → https://oauth2-proxy.example.com/oauth2/sign_in?rd=https://app.example.com/page
     end
 ```
 
 NGINX captures identity headers from the oauth2-proxy subrequest (`X-Auth-Request-User`, `X-Auth-Request-Email`, `X-Auth-Request-Groups`, and the access token) and forwards them to the backend container via `proxy_set_header` under a stable contract (`X-User`, `X-Email`, `X-Groups`, `X-Access-Token`). This requires oauth2-proxy to run with `--set-xauthrequest` (and `--pass-access-token` for the token).
 
-If `authRequired` is enabled but no `authServer` is configured on the domain, NGINX serves a 503 error page.
+If `authRequired` is enabled but no (valid) `authServer` is configured on the domain, NGINX serves a 503 error page.
 

From 43cee14dbbdae8ca8aa57734afbdce3d8c936e00 Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Tue, 16 Jun 2026 11:30:08 -0400
Subject: [PATCH 03/20] Address PR review: validate authServer in API, use
 $proxy_host

- Move authServer URL validation to the model (API layer): replace the
  permissive `isUrl: true` (which accepts scheme-less hosts like
  "oauth2-proxy.example.com") with a custom validator requiring an
  absolute http(s) URL. A malformed value is now rejected on create/
  update with a 422 and never reaches the nginx template.
- Drop the template-side `new URL()` parsing / authHost / authEnabled.
  The auth block again gates on `authRequired && authServer`.
- Use `proxy_set_header Host $proxy_host;` (nginx's default, taken from
  the proxy_pass URL) instead of injecting a parsed host. This keeps the
  loop guard (Host = the oauth2-proxy host, never $host) without the
  template re-parsing the URL.

Refs #348
---
 create-a-container/models/external-domain.js  | 17 ++++++++-
 create-a-container/views/nginx-conf.ejs       | 35 +++++++------------
 .../admins/core-concepts/external-domains.md  |  2 +-
 .../docs/developers/system-architecture.md    |  2 +-
 4 files changed, 30 insertions(+), 26 deletions(-)

diff --git a/create-a-container/models/external-domain.js b/create-a-container/models/external-domain.js
index 681b057c..aeeeca0e 100644
--- a/create-a-container/models/external-domain.js
+++ b/create-a-container/models/external-domain.js
@@ -57,7 +57,22 @@ module.exports = (sequelize) => {
       type: DataTypes.STRING,
       allowNull: true,
       validate: {
-        isUrl: true
+        // Must be an absolute http(s) URL — it is interpolated directly into
+        // nginx `proxy_pass`, which requires a scheme. Reject scheme-less hosts
+        // (e.g. "oauth2-proxy.example.com") and non-http schemes here so a bad
+        // value can never reach the generated config.
+        isHttpUrl(value) {
+          if (value === null || value === undefined || value === '') return;
+          let url;
+          try {
+            url = new URL(value);
+          } catch (e) {
+            throw new Error('authServer must be an absolute URL, e.g. https://oauth2-proxy.example.com');
+          }
+          if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+            throw new Error('authServer must use http or https');
+          }
+        }
       },
       comment: 'Public oauth2-proxy URL for nginx auth_request (e.g., https://oauth2-proxy.example.com). A routable host on the same load balancer that proxies to an administrator-run oauth2-proxy; protected services delegate auth to it.'
     }
diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs
index 8e8cb768..d369b905 100644
--- a/create-a-container/views/nginx-conf.ejs
+++ b/create-a-container/views/nginx-conf.ejs
@@ -185,20 +185,6 @@ http {
   <%_ httpServices.forEach((service, index) => { _%>
   <%_ const authRequired = service.httpService.authRequired; _%>
   <%_ const authServer = service.httpService.externalDomain.authServer; _%>
-  <%_
-    // The auth server (oauth2-proxy) is a routable host on this same load
-    // balancer (e.g. https://oauth2-proxy.example.com). When NGINX proxies the
-    // /oauth2/* paths to it, it must send Host = the auth host's own name — NOT
-    // $host — otherwise the proxied request would re-match THIS app's server
-    // block and loop through auth_request. Parse the host out of authServer so
-    // we can pin the upstream Host header. Falls back to disabling auth if the
-    // URL is unparseable.
-    let authHost = null;
-    if (authRequired && authServer) {
-      try { authHost = new URL(authServer).host; } catch (e) { authHost = null; }
-    }
-    const authEnabled = authRequired && authServer && authHost;
-  _%>
   server {
     listen 443 ssl;
     listen [::]:443 ssl;
@@ -250,7 +236,7 @@ http {
       proxy_pass http://error_pages;
     }
 
-    <%_ if (authEnabled) { _%>
+    <%_ if (authRequired && authServer) { _%>
     # --- oauth2-proxy integration -------------------------------------------
     # The administrator runs a single oauth2-proxy, published as a routable host
     # on this same load balancer (the domain's "oauth2-proxy URL", e.g.
@@ -259,9 +245,11 @@ http {
     # on the load balancer delegates to it via auth_request.
     #
     # IMPORTANT: the subrequest below proxies to the auth host over the load
-    # balancer, so it MUST send `Host: <%= authHost %>` (the auth host's own
-    # name) and NOT `$host`. With `$host`, the proxied request would re-match
-    # THIS app's server block and loop through auth_request indefinitely.
+    # balancer, so it MUST send the auth host's own name as `Host` and NOT
+    # `$host`. With `$host`, the proxied request would re-match THIS app's server
+    # block and loop through auth_request indefinitely. We use `$proxy_host`
+    # (nginx's default Host, taken from the proxy_pass URL) so the Host always
+    # matches the auth host without re-parsing the URL ourselves.
     # See https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/
 
     # Internal auth check. oauth2-proxy returns 202 (authenticated) or 401.
@@ -270,8 +258,9 @@ http {
     location = /oauth2/auth {
       internal;
       proxy_pass <%= authServer %>/oauth2/auth;
-      # Pin Host to the auth host (loop guard — see note above).
-      proxy_set_header Host                    <%= authHost %>;
+      # Pin Host to the auth host (loop guard — see note above). $proxy_host is
+      # the host from the proxy_pass URL, i.e. the oauth2-proxy host, never $host.
+      proxy_set_header Host                    $proxy_host;
       proxy_ssl_server_name on;
       proxy_set_header X-Real-IP               $remote_addr;
       proxy_set_header X-Scheme                $scheme;
@@ -306,8 +295,8 @@ http {
     auth_request_set $auth_token $upstream_http_x_auth_request_access_token;
 
     <%_ } _%>
-    <%_ if (authRequired && !authEnabled) { _%>
-    # Authentication required but no (valid) oauth2-proxy URL configured for this domain
+    <%_ if (authRequired && !authServer) { _%>
+    # Authentication required but no oauth2-proxy URL configured for this domain
     location @auth_unavailable {
       rewrite ^ /auth-unavailable.html break;
       proxy_method GET;
@@ -323,7 +312,7 @@ http {
     <%_ } else { _%>
     # Proxy settings
     location / {
-      <%_ if (authEnabled) { _%>
+      <%_ if (authRequired && authServer) { _%>
       auth_request /oauth2/auth;
       error_page 401 = @oauth2_signin;
 
diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index 8041788b..e751770d 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -175,5 +175,5 @@ sequenceDiagram
     end
 ```
 
-If **Require auth** is enabled but no (valid) **oauth2-proxy URL** is configured on the domain, NGINX serves a 503 "Authentication Unavailable" page.
+If **Require auth** is enabled but no **oauth2-proxy URL** is configured on the domain, NGINX serves a 503 "Authentication Unavailable" page.
 
diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md
index d71f2405..6013c571 100644
--- a/mie-opensource-landing/docs/developers/system-architecture.md
+++ b/mie-opensource-landing/docs/developers/system-architecture.md
@@ -172,5 +172,5 @@ sequenceDiagram
 
 NGINX captures identity headers from the oauth2-proxy subrequest (`X-Auth-Request-User`, `X-Auth-Request-Email`, `X-Auth-Request-Groups`, and the access token) and forwards them to the backend container via `proxy_set_header` under a stable contract (`X-User`, `X-Email`, `X-Groups`, `X-Access-Token`). This requires oauth2-proxy to run with `--set-xauthrequest` (and `--pass-access-token` for the token).
 
-If `authRequired` is enabled but no (valid) `authServer` is configured on the domain, NGINX serves a 503 error page.
+If `authRequired` is enabled but no `authServer` is configured on the domain, NGINX serves a 503 error page. (A malformed `authServer` cannot reach the template — it is rejected by the API on write.)
 

From 724779b9a3a6d3d632cf9e20b121245cd98aa43f Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Tue, 16 Jun 2026 11:52:15 -0400
Subject: [PATCH 04/20] cleanup

---
 .../ExternalDomainFormPage.tsx                |  2 +-
 create-a-container/models/external-domain.js  |  2 +-
 create-a-container/server.js                  |  4 ---
 create-a-container/views/nginx-conf.ejs       | 35 +------------------
 4 files changed, 3 insertions(+), 40 deletions(-)

diff --git a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx
index 6c26b6a0..e6031452 100644
--- a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx
+++ b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx
@@ -168,7 +168,7 @@ export function ExternalDomainFormPage() {
         <Input
           label="oauth2-proxy URL"
           placeholder="https://oauth2-proxy.example.com"
-          helperText="Optional. Public URL of an oauth2-proxy server (a routable host on this load balancer) used for nginx auth_request."
+          helperText="Optional. URL of an oauth2-proxy server used for nginx auth_request."
           {...register('authServer')}
         />
         {mutation.error && (
diff --git a/create-a-container/models/external-domain.js b/create-a-container/models/external-domain.js
index aeeeca0e..2f5ded01 100644
--- a/create-a-container/models/external-domain.js
+++ b/create-a-container/models/external-domain.js
@@ -74,7 +74,7 @@ module.exports = (sequelize) => {
           }
         }
       },
-      comment: 'Public oauth2-proxy URL for nginx auth_request (e.g., https://oauth2-proxy.example.com). A routable host on the same load balancer that proxies to an administrator-run oauth2-proxy; protected services delegate auth to it.'
+      comment: 'oauth2-proxy URL for nginx auth_request (e.g., https://oauth2-proxy.example.com)'
     }
   }, {
     tableName: 'ExternalDomains',
diff --git a/create-a-container/server.js b/create-a-container/server.js
index 73726e01..62b4aee0 100644
--- a/create-a-container/server.js
+++ b/create-a-container/server.js
@@ -57,10 +57,6 @@ async function main() {
     store: sessionStore,
     resave: false,
     saveUninitialized: false,
-    // The manager's session cookie only needs to be valid for the manager
-    // host itself — forward-auth for other subdomains is handled by an
-    // external oauth2-proxy server, which manages its own cookies. We leave
-    // the cookie scoped to the exact host (no `domain` attribute).
     // `secure` is derived from the request protocol (honoring `trust proxy`
     // and X-Forwarded-Proto from nginx) rather than NODE_ENV, so the flag
     // tracks the actual transport — set on HTTPS, omitted on plain HTTP
diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs
index d369b905..5e75c77d 100644
--- a/create-a-container/views/nginx-conf.ejs
+++ b/create-a-container/views/nginx-conf.ejs
@@ -237,37 +237,14 @@ http {
     }
 
     <%_ if (authRequired && authServer) { _%>
-    # --- oauth2-proxy integration -------------------------------------------
-    # The administrator runs a single oauth2-proxy, published as a routable host
-    # on this same load balancer (the domain's "oauth2-proxy URL", e.g.
-    # https://oauth2-proxy.example.com — configured as its own external-domain
-    # service that proxies to the oauth2-proxy process). Every authenticated app
-    # on the load balancer delegates to it via auth_request.
-    #
-    # IMPORTANT: the subrequest below proxies to the auth host over the load
-    # balancer, so it MUST send the auth host's own name as `Host` and NOT
-    # `$host`. With `$host`, the proxied request would re-match THIS app's server
-    # block and loop through auth_request indefinitely. We use `$proxy_host`
-    # (nginx's default Host, taken from the proxy_pass URL) so the Host always
-    # matches the auth host without re-parsing the URL ourselves.
-    # See https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/
-
-    # Internal auth check. oauth2-proxy returns 202 (authenticated) or 401.
-    # Responses are cached per Cookie+Authorization pair so NGINX only contacts
-    # oauth2-proxy when the credentials change.
     location = /oauth2/auth {
       internal;
       proxy_pass <%= authServer %>/oauth2/auth;
-      # Pin Host to the auth host (loop guard — see note above). $proxy_host is
-      # the host from the proxy_pass URL, i.e. the oauth2-proxy host, never $host.
       proxy_set_header Host                    $proxy_host;
       proxy_ssl_server_name on;
       proxy_set_header X-Real-IP               $remote_addr;
       proxy_set_header X-Scheme                $scheme;
-      # Preserve the ORIGINAL app URL so oauth2-proxy returns the user here
-      # after sign-in (absolute form is required for the multi-domain setup).
       proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri;
-      # nginx auth_request includes headers but not the request body.
       proxy_set_header Content-Length          "";
       proxy_pass_request_body                  off;
 
@@ -277,18 +254,10 @@ http {
       proxy_cache_valid 401 30s;
     }
 
-    # On 401, send the browser to oauth2-proxy's sign-in page on the auth host.
-    # The browser authenticates there, the session cookie is set on the shared
-    # parent domain (oauth2-proxy --cookie-domain=.<domain>), and `rd` brings the
-    # user back to this app afterward.
     location @oauth2_signin {
       return 302 <%= authServer %>/oauth2/sign_in?rd=$scheme://$host$request_uri;
     }
 
-    # Capture identity from the auth_request response. Requires oauth2-proxy to
-    # run with --set-xauthrequest (and --pass-access-token for the access token).
-    # oauth2-proxy returns these as X-Auth-Request-* headers; we forward them to
-    # the backend under the stable X-User / X-Email contract (see below).
     auth_request_set $auth_user  $upstream_http_x_auth_request_user;
     auth_request_set $auth_email $upstream_http_x_auth_request_email;
     auth_request_set $auth_groups $upstream_http_x_auth_request_groups;
@@ -316,9 +285,7 @@ http {
       auth_request /oauth2/auth;
       error_page 401 = @oauth2_signin;
 
-      # Forward identity to the backend under a stable header contract.
-      # (oauth2-proxy's own X-Auth-Request-* names are mapped here so the
-      # backend contract stays the same regardless of the auth provider.)
+      # Forward user identity from auth subrequest to backend
       proxy_set_header X-User         $auth_user;
       proxy_set_header X-Email        $auth_email;
       proxy_set_header X-Groups       $auth_groups;

From d35d7b651d75517a70647e45b7ef25a18065a49e Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Tue, 16 Jun 2026 12:08:25 -0400
Subject: [PATCH 05/20] Add X-Forwarded-Uri to the oauth2-proxy auth subrequest

oauth2-proxy's nginx auth_request integration expects X-Forwarded-Uri on
the /oauth2/auth subrequest so it can reconstruct the original request URI
for redirect/upstream context. Matches the upstream example config.

Refs #348
---
 create-a-container/views/nginx-conf.ejs | 1 +
 1 file changed, 1 insertion(+)

diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs
index 5e75c77d..ca3d3385 100644
--- a/create-a-container/views/nginx-conf.ejs
+++ b/create-a-container/views/nginx-conf.ejs
@@ -244,6 +244,7 @@ http {
       proxy_ssl_server_name on;
       proxy_set_header X-Real-IP               $remote_addr;
       proxy_set_header X-Scheme                $scheme;
+      proxy_set_header X-Forwarded-Uri         $request_uri;
       proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri;
       proxy_set_header Content-Length          "";
       proxy_pass_request_body                  off;

From da2ad4d0643e46b803f9e92eb6f09ed0303ecacd Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Tue, 16 Jun 2026 17:07:44 -0400
Subject: [PATCH 06/20] Proxy /oauth2/* to oauth2-proxy in a single hop
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Run oauth2-proxy as a standalone process on its own address (authServer,
e.g. http://127.0.0.1:4180) and proxy the whole /oauth2/* subtree — plus
the auth_request check at /oauth2/auth — straight to it, passing the
app's own Host through. oauth2-proxy then terminates these requests
directly, so it builds redirect URIs / cookies against the correct app
hostname and needs no --reverse-proxy, --real-ip-from, or X-Forwarded-*
headers.

This removes the previous two-hop design (app -> routable oauth2-proxy
vhost -> process), which let the proxy's own server block clobber
X-Forwarded-Host and produced redirect_uris on the proxy's hostname. It
also drops the Host-pinning loop guard and proxy_ssl_server_name, which
are no longer needed.

Admins who want oauth2-proxy behind the same load-balancer IP can expose
its port via an L4 (stream{}) passthrough. Request scheme follows the
authServer protocol; pair an http:// upstream with --force-https /
--cookie-secure for HTTPS browsers.

Updates the authServer validator/comment, the form helper, and the docs
accordingly.

Refs #348
---
 .../ExternalDomainFormPage.tsx                |  4 +-
 create-a-container/models/external-domain.js  |  8 ++--
 create-a-container/views/nginx-conf.ejs       | 27 ++++++++-----
 .../admins/core-concepts/external-domains.md  | 38 +++++++++++--------
 .../docs/admins/installation.md               |  2 +-
 .../docs/developers/database-schema.md        |  4 +-
 .../docs/developers/system-architecture.md    | 10 ++---
 7 files changed, 54 insertions(+), 39 deletions(-)

diff --git a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx
index e6031452..a3f1cf14 100644
--- a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx
+++ b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx
@@ -167,8 +167,8 @@ export function ExternalDomainFormPage() {
         />
         <Input
           label="oauth2-proxy URL"
-          placeholder="https://oauth2-proxy.example.com"
-          helperText="Optional. URL of an oauth2-proxy server used for nginx auth_request."
+          placeholder="http://127.0.0.1:4180"
+          helperText="Optional. Address of an oauth2-proxy process used for nginx auth_request. nginx proxies /oauth2/* straight to it."
           {...register('authServer')}
         />
         {mutation.error && (
diff --git a/create-a-container/models/external-domain.js b/create-a-container/models/external-domain.js
index 2f5ded01..62d0ff8b 100644
--- a/create-a-container/models/external-domain.js
+++ b/create-a-container/models/external-domain.js
@@ -59,22 +59,22 @@ module.exports = (sequelize) => {
       validate: {
         // Must be an absolute http(s) URL — it is interpolated directly into
         // nginx `proxy_pass`, which requires a scheme. Reject scheme-less hosts
-        // (e.g. "oauth2-proxy.example.com") and non-http schemes here so a bad
-        // value can never reach the generated config.
+        // (e.g. "oauth2-proxy:4180") and non-http schemes here so a bad value
+        // can never reach the generated config.
         isHttpUrl(value) {
           if (value === null || value === undefined || value === '') return;
           let url;
           try {
             url = new URL(value);
           } catch (e) {
-            throw new Error('authServer must be an absolute URL, e.g. https://oauth2-proxy.example.com');
+            throw new Error('authServer must be an absolute URL, e.g. http://127.0.0.1:4180');
           }
           if (url.protocol !== 'http:' && url.protocol !== 'https:') {
             throw new Error('authServer must use http or https');
           }
         }
       },
-      comment: 'oauth2-proxy URL for nginx auth_request (e.g., https://oauth2-proxy.example.com)'
+      comment: "Address of the oauth2-proxy process for nginx auth_request, e.g. http://127.0.0.1:4180. nginx proxies /oauth2/* straight to it in a single hop; do not point this at a path-prefixed URL."
     }
   }, {
     tableName: 'ExternalDomains',
diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs
index ca3d3385..66803561 100644
--- a/create-a-container/views/nginx-conf.ejs
+++ b/create-a-container/views/nginx-conf.ejs
@@ -237,17 +237,26 @@ http {
     }
 
     <%_ if (authRequired && authServer) { _%>
+    # oauth2-proxy runs on its own address (this domain's "oauth2-proxy URL").
+    # We proxy the whole /oauth2/* subtree straight to it in a single hop and
+    # pass the app's own Host through, so oauth2-proxy builds redirect URIs and
+    # cookies against this app's hostname. Because it terminates these requests
+    # directly (no proxy in front of it from its point of view), oauth2-proxy does
+    # NOT need --reverse-proxy or any X-Forwarded-* headers. The request scheme is
+    # whatever <%= authServer %> uses; pair an http:// upstream with
+    # --force-https / --cookie-secure on oauth2-proxy if the browser is on HTTPS.
+    location /oauth2/ {
+      proxy_pass <%= authServer %>;
+      proxy_set_header Host $host;
+    }
+
     location = /oauth2/auth {
       internal;
       proxy_pass <%= authServer %>/oauth2/auth;
-      proxy_set_header Host                    $proxy_host;
-      proxy_ssl_server_name on;
-      proxy_set_header X-Real-IP               $remote_addr;
-      proxy_set_header X-Scheme                $scheme;
-      proxy_set_header X-Forwarded-Uri         $request_uri;
-      proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri;
-      proxy_set_header Content-Length          "";
-      proxy_pass_request_body                  off;
+      proxy_set_header Host             $host;
+      # nginx auth_request includes headers but not the request body.
+      proxy_set_header Content-Length   "";
+      proxy_pass_request_body           off;
 
       proxy_cache auth_cache;
       proxy_cache_key "$http_cookie$http_authorization";
@@ -256,7 +265,7 @@ http {
     }
 
     location @oauth2_signin {
-      return 302 <%= authServer %>/oauth2/sign_in?rd=$scheme://$host$request_uri;
+      return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri;
     }
 
     auth_request_set $auth_user  $upstream_http_x_auth_request_user;
diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index e751770d..ba7f358a 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -18,7 +18,7 @@ External domains expose container HTTP services to the internet. Domains are glo
 | **ACME Directory** | CA endpoint, Let's Encrypt Production/Staging (currently unused) |
 | **Cloudflare API Email** | Cloudflare account email, sent as the `X-Auth-Email` header — optional unless using Cross-Site DNS |
 | **Cloudflare API Key** | Cloudflare **User API Token**, sent as `Authorization: Bearer <token>`. Despite the field name, this is *not* the legacy Global API Key. Optional unless using Cross-Site DNS. |
-| **oauth2-proxy URL** | Optional — public URL of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server (a routable host on this load balancer, e.g. `https://oauth2-proxy.example.com`) for NGINX `auth_request`. See [Authentication](#authentication) |
+| **oauth2-proxy URL** | Optional — address of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) process (e.g. `http://127.0.0.1:4180`) that NGINX proxies `/oauth2/*` to for `auth_request`. See [Authentication](#authentication) |
 
 !!! tip
     Use Let's Encrypt **Staging** for testing — it has higher rate limits. Switch to **Production** once verified.
@@ -117,26 +117,31 @@ The manager does **not** provide authentication itself — you must deploy and c
 
 ### Configuring oauth2-proxy
 
-A **single** oauth2-proxy instance can authenticate every service on the load balancer. Publish it as its own routable host (e.g. `oauth2-proxy.example.com`) and set the domain's **oauth2-proxy URL** to that public URL (e.g. `https://oauth2-proxy.example.com`).
+Run oauth2-proxy as a standalone process listening on its own address, and set the domain's **oauth2-proxy URL** to that address (e.g. `http://127.0.0.1:4180`). NGINX proxies the whole `/oauth2/*` path on each protected service straight to that address in a **single hop**, so to the browser the OAuth2 endpoints appear under the app's own hostname (`app.example.com/oauth2/...`) while actually being served by oauth2-proxy.
 
-1. **Expose oauth2-proxy as its own service.** Create an HTTP service (e.g. hostname `oauth2-proxy` on `example.com`) that proxies to your oauth2-proxy process, and leave **Require auth disabled** on it — it *is* the auth server, so it must never require auth itself (doing so would make it call `auth_request` against itself and loop).
-2. **Point protected domains at it.** Set the **oauth2-proxy URL** on each external domain whose services should require auth to `https://oauth2-proxy.example.com`.
+1. **Run oauth2-proxy** on a fixed address reachable from the NGINX host (loopback if co-located, e.g. `http://127.0.0.1:4180`, or a private host/port).
+2. **Point protected domains at it.** Set the **oauth2-proxy URL** on each external domain whose services should require auth.
 3. **Enable Require auth** on the individual services you want protected.
 
-When a protected service (e.g. `app.example.com`) receives a request, NGINX issues an `auth_request` subrequest to `https://oauth2-proxy.example.com/oauth2/auth`. On `401`, the browser is redirected to `https://oauth2-proxy.example.com/oauth2/sign_in?rd=<original-url>`; after sign-in the user is sent back to the originating service.
+A single oauth2-proxy instance can serve many services this way — they all proxy `/oauth2/*` to the same address. Because NGINX passes each app's own `Host` through, oauth2-proxy builds redirect URIs and cookies against the correct app hostname without any extra configuration.
+
+!!! note "Putting oauth2-proxy behind the same load balancer"
+    oauth2-proxy does **not** need to be on the NGINX host. If you want it to live behind the same load-balancer IP, expose its port with an L4 (TCP) passthrough — e.g. a **transport service**, which NGINX serves via its `stream {}` block — and point the **oauth2-proxy URL** at that address. The `/oauth2/*` traffic still reaches oauth2-proxy in a single hop, so none of the header/`--reverse-proxy` handling below is required.
 
 Run oauth2-proxy with at least:
 
-- `--reverse-proxy=true` — required when behind NGINX.
 - `--set-xauthrequest=true` — so identity is returned in `X-Auth-Request-*` response headers (forwarded to the backend).
-- `--cookie-domain=.example.com` — so the session cookie is shared across all sibling subdomains (required for one oauth2-proxy to serve multiple hosts).
-- `--whitelist-domain=.example.com` — so post-sign-in redirects back to your app hosts are allowed.
 - `--pass-access-token=true` *(optional)* — to forward the access token as `X-Access-Token`.
 
-See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/) for full configuration details.
+Because NGINX proxies straight to oauth2-proxy (nothing sits in front of it from its point of view), you do **not** need `--reverse-proxy`, `--real-ip-from`, or any `X-Forwarded-*` headers.
+
+!!! warning "HTTPS scheme"
+    oauth2-proxy builds its `redirect_uri` and secure cookies from the scheme of the connection it receives. The scheme is whatever you put in the **oauth2-proxy URL**:
 
-!!! warning "Loop protection"
-    When NGINX proxies the auth subrequest to `oauth2-proxy.example.com` over the same load balancer, it sends `Host: oauth2-proxy.example.com` (the auth host's own name) — **not** the app's host. This is what makes the request land on the oauth2-proxy server block instead of re-matching the app's block and looping through `auth_request`. The generated config does this automatically; just make sure the **oauth2-proxy URL** is the auth server's own public hostname and that **Require auth is off** on the oauth2-proxy service.
+    - **`http://…` upstream** (most common, e.g. `http://127.0.0.1:4180`): oauth2-proxy sees a plain-HTTP connection. Run it with `--force-https=true` and `--cookie-secure=true` so it still emits `https://` redirect URIs and secure cookies for HTTPS browsers.
+    - **`https://…` upstream**: terminate TLS on the oauth2-proxy listener; oauth2-proxy infers HTTPS from the connection directly.
+
+See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/) for full configuration details.
 
 ### Identity Headers
 
@@ -149,9 +154,9 @@ When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Req
 | `X-Groups` | `X-Auth-Request-Groups` |
 | `X-Access-Token` | `X-Auth-Request-Access-Token` (with `--pass-access-token`) |
 
-### Cookie Domain
+### Sharing one sign-in across subdomains
 
-A single oauth2-proxy serving multiple hosts must set its session cookie on the shared parent domain so every app subdomain can present it on the `auth_request` subrequest. Configure oauth2-proxy with `--cookie-domain=.example.com` and `--whitelist-domain=.example.com`. The auth server and all protected services must therefore be subdomains of the same parent domain.
+Because `/oauth2/*` is served on each app's own hostname, the oauth2-proxy cookie is scoped to that host by default. To share a single sign-in across multiple subdomains, run oauth2-proxy with `--cookie-domain=.example.com` and `--whitelist-domain=.example.com`, and make the protected services subdomains of the same parent domain.
 
 ### Flow
 
@@ -159,11 +164,11 @@ A single oauth2-proxy serving multiple hosts must set its session cookie on the
 sequenceDiagram
     participant Client
     participant NGINX
-    participant OAuth2Proxy as oauth2-proxy.example.com
+    participant OAuth2Proxy as oauth2-proxy
     participant Backend
 
     Client->>NGINX: GET app.example.com/page
-    NGINX->>OAuth2Proxy: auth_request → GET /oauth2/auth (Host: oauth2-proxy.example.com)
+    NGINX->>OAuth2Proxy: auth_request → GET /oauth2/auth (Host: app.example.com)
     alt 202 (authenticated)
         OAuth2Proxy-->>NGINX: 202 + X-Auth-Request-* headers
         NGINX->>Backend: Proxied request + identity headers
@@ -171,7 +176,8 @@ sequenceDiagram
         NGINX-->>Client: Response
     else 401 (unauthenticated)
         OAuth2Proxy-->>NGINX: 401
-        NGINX-->>Client: 302 → https://oauth2-proxy.example.com/oauth2/sign_in?rd=https://app.example.com/page
+        NGINX-->>Client: 302 → app.example.com/oauth2/sign_in?rd=https://app.example.com/page
+        Note over Client,OAuth2Proxy: /oauth2/* is proxied to oauth2-proxy in one hop
     end
 ```
 
diff --git a/mie-opensource-landing/docs/admins/installation.md b/mie-opensource-landing/docs/admins/installation.md
index 588460d7..0e518900 100644
--- a/mie-opensource-landing/docs/admins/installation.md
+++ b/mie-opensource-landing/docs/admins/installation.md
@@ -104,7 +104,7 @@ Further reading: [External Domains](core-concepts/external-domains.md).
     2. **Default Site**: `First Site`
     3. **ACME Email** and **ACME Directory** are currently unused.
     4. **Cloudflare API Email** and **Key** are optional unless you are planning to use Cross-Site DNS.
-    5. **oauth2-proxy URL**: optional — the public URL of an oauth2-proxy server (a routable host on this load balancer, e.g. `https://oauth2-proxy.example.org`) if you want to require authentication for services on this domain (see [Authentication](core-concepts/external-domains.md#authentication)).
+    5. **oauth2-proxy URL**: optional — the address of an oauth2-proxy process (e.g. `http://127.0.0.1:4180`) if you want to require authentication for services on this domain (see [Authentication](core-concepts/external-domains.md#authentication)).
 3. Select "Create External Domain".
 4. Refer to [SSL Certificate Provisioning](core-concepts/external-domains.md#ssl-certificate-provisioning) to configure an HTTPS certificate.
 
diff --git a/mie-opensource-landing/docs/developers/database-schema.md b/mie-opensource-landing/docs/developers/database-schema.md
index ab1f3f24..6b567db0 100644
--- a/mie-opensource-landing/docs/developers/database-schema.md
+++ b/mie-opensource-landing/docs/developers/database-schema.md
@@ -105,7 +105,7 @@ erDiagram
         string cloudflareApiEmail
         string cloudflareApiKey
         int siteId FK "nullable, default site"
-        string authServer "nullable, public oauth2-proxy URL"
+        string authServer "nullable, oauth2-proxy process address"
     }
 
     Jobs {
@@ -190,7 +190,7 @@ Base model with `type` discriminator (`http`, `transport`, `dns`). Belongs to Co
 - **DnsService**: SRV records with `serviceName`.
 
 ### ExternalDomain
-Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional public URL of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) host (a routable host on the same load balancer) used for NGINX `auth_request` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)).
+Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional address of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) process (e.g. `http://127.0.0.1:4180`) that NGINX proxies `/oauth2/*` to for `auth_request` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)).
 
 ## User Management Models
 
diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md
index 6013c571..b80be4bd 100644
--- a/mie-opensource-landing/docs/developers/system-architecture.md
+++ b/mie-opensource-landing/docs/developers/system-architecture.md
@@ -146,19 +146,19 @@ sequenceDiagram
 
 ### Authenticated HTTP Services
 
-When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests against an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) server before proxying. The domain's `authServer` is the oauth2-proxy server's public URL — a routable host on the same load balancer, e.g. `https://oauth2-proxy.example.com` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). The manager itself no longer provides forward-auth — administrators run and configure oauth2-proxy.
+When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests against an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) process before proxying. The domain's `authServer` is the address of that process, e.g. `http://127.0.0.1:4180` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). The manager itself no longer provides forward-auth — administrators run and configure oauth2-proxy.
 
-The auth subrequest is proxied to that host **with `Host` pinned to the auth host's own name** (`new URL(authServer).host`), not the app's `$host`. Because the subrequest travels back over the load balancer, using `$host` would re-match the app's own `server` block and loop through `auth_request`; pinning `Host` makes it land on the oauth2-proxy `server` block instead. The redirect target (`X-Auth-Request-Redirect` / `rd=`) uses the absolute `$scheme://$host$request_uri` so one oauth2-proxy can serve many app hosts.
+NGINX proxies the whole `/oauth2/*` subtree (and the `auth_request` check at `/oauth2/auth`) straight to `authServer` in a **single hop**, passing the app's own `Host` through. Because oauth2-proxy terminates these requests directly, it builds redirect URIs and cookies against the correct app hostname and needs no `--reverse-proxy` / `X-Forwarded-*` handling. If the admin wants oauth2-proxy behind the same load-balancer IP, they expose its port via an L4 (`stream {}`) passthrough; it is never fronted by a generated `server` block, so there is no second hop to clobber headers.
 
 ```mermaid
 sequenceDiagram
     participant Client
     participant NGINX
-    participant OAuth2Proxy as oauth2-proxy.example.com
+    participant OAuth2Proxy as oauth2-proxy
     participant Container
 
     Client->>NGINX: GET app.example.com/page
-    NGINX->>OAuth2Proxy: Subrequest: GET /oauth2/auth (Host: oauth2-proxy.example.com)
+    NGINX->>OAuth2Proxy: Subrequest: GET /oauth2/auth (Host: app.example.com)
     alt 202 (authenticated)
         OAuth2Proxy-->>NGINX: 202 + X-Auth-Request-* headers
         NGINX->>Container: Proxied request + identity headers
@@ -166,7 +166,7 @@ sequenceDiagram
         NGINX-->>Client: Response
     else 401 (unauthenticated)
         OAuth2Proxy-->>NGINX: 401
-        NGINX-->>Client: 302 → https://oauth2-proxy.example.com/oauth2/sign_in?rd=https://app.example.com/page
+        NGINX-->>Client: 302 → app.example.com/oauth2/sign_in?rd=https://app.example.com/page
     end
 ```
 

From 1505aadeb64f8fa358d551331003cbecd01ad994 Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Tue, 16 Jun 2026 17:20:02 -0400
Subject: [PATCH 07/20] Fix @502 on protected domains; document redis session
 store
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Move the auth_request_set directives from server scope into `location /`
(alongside auth_request). At server scope they were evaluated for the
error_page named locations (@502/@403) too, which have no auth subrequest
— breaking those internal redirects, so a signed-in user hitting a
backend 502 got nginx's default page instead of @502. This matches the
upstream oauth2-proxy nginx example, which keeps auth_request_set inside
location /.

Also document --session-store-type=redis as the fix for oversized
_oauth2_proxy cookies (the default cookie store packs all tokens into the
cookie and can exceed NGINX's header buffers).

Refs #348
---
 create-a-container/views/nginx-conf.ejs          | 16 ++++++++++------
 .../admins/core-concepts/external-domains.md     | 12 ++++++++++++
 2 files changed, 22 insertions(+), 6 deletions(-)

diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs
index 66803561..684dd719 100644
--- a/create-a-container/views/nginx-conf.ejs
+++ b/create-a-container/views/nginx-conf.ejs
@@ -268,11 +268,6 @@ http {
       return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri;
     }
 
-    auth_request_set $auth_user  $upstream_http_x_auth_request_user;
-    auth_request_set $auth_email $upstream_http_x_auth_request_email;
-    auth_request_set $auth_groups $upstream_http_x_auth_request_groups;
-    auth_request_set $auth_token $upstream_http_x_auth_request_access_token;
-
     <%_ } _%>
     <%_ if (authRequired && !authServer) { _%>
     # Authentication required but no oauth2-proxy URL configured for this domain
@@ -295,7 +290,16 @@ http {
       auth_request /oauth2/auth;
       error_page 401 = @oauth2_signin;
 
-      # Forward user identity from auth subrequest to backend
+      # Capture identity from the auth subrequest and forward it to the backend.
+      # These auth_request_set directives must live here (alongside auth_request),
+      # not at server scope — otherwise nginx evaluates them for error_page
+      # locations like @502 that have no auth subrequest, which breaks those
+      # internal redirects.
+      auth_request_set $auth_user   $upstream_http_x_auth_request_user;
+      auth_request_set $auth_email  $upstream_http_x_auth_request_email;
+      auth_request_set $auth_groups $upstream_http_x_auth_request_groups;
+      auth_request_set $auth_token  $upstream_http_x_auth_request_access_token;
+
       proxy_set_header X-User         $auth_user;
       proxy_set_header X-Email        $auth_email;
       proxy_set_header X-Groups       $auth_groups;
diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index ba7f358a..b5707d57 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -143,6 +143,18 @@ Because NGINX proxies straight to oauth2-proxy (nothing sits in front of it from
 
 See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/) for full configuration details.
 
+!!! warning "Large session cookies"
+    With the default **cookie** session store, oauth2-proxy packs the entire encrypted session (access, refresh, and ID tokens plus claims) into the `_oauth2_proxy` cookie, sent on every request. This easily exceeds 4&nbsp;KB and can trip NGINX header-buffer limits (e.g. a `502` on the `/oauth2/callback` response).
+
+    Use the **Redis** session store so only a small ticket is stored in the cookie:
+
+    ```
+    --session-store-type=redis
+    --redis-connection-url=redis://<host>:6379
+    ```
+
+    If you cannot run Redis, reduce what the cookie carries instead: drop `--pass-access-token` / `--set-authorization-header` if you do not need the token downstream, and request only the scopes you use. See the [session storage docs](https://oauth2-proxy.github.io/oauth2-proxy/configuration/session_storage/).
+
 ### Identity Headers
 
 When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Request-*` response headers and forwards them to the backend under a **stable header contract** (so the backend sees the same names regardless of the auth provider):

From f0a58379e012ba69ec0d07e729679b41e7f0bf7c Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Tue, 16 Jun 2026 17:34:16 -0400
Subject: [PATCH 08/20] Re-declare error_page in protected location / so
 @502/@403 work

Root cause of the default 502 page on auth-enabled domains: a
location-level `error_page` REPLACES (does not merge with) the
server-level error_page list. Adding `error_page 401 = @oauth2_signin`
inside `location /` therefore dropped the server-level
`error_page 403 @403; error_page 502 @502;` for that location, so a
signed-in user hitting a down backend got nginx's built-in 502 page.

Re-declare error_page 403/502 inside the auth-enabled location /.
Verified with a local nginx repro (auth subrequest 202 + dead backend):
without the re-declaration nginx serves the default 502; with it, the
custom @502 page renders.

Also corrects the auth_request_set comment from the previous commit (the
server-scope placement was not the cause; the error_page override was).

Refs #348
---
 create-a-container/views/nginx-conf.ejs | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs
index 684dd719..a0322206 100644
--- a/create-a-container/views/nginx-conf.ejs
+++ b/create-a-container/views/nginx-conf.ejs
@@ -289,12 +289,14 @@ http {
       <%_ if (authRequired && authServer) { _%>
       auth_request /oauth2/auth;
       error_page 401 = @oauth2_signin;
+      # A location-level error_page replaces (does not merge with) the
+      # server-level error_page list, so the @403/@502 handlers defined above
+      # must be re-declared here or this location would fall back to nginx's
+      # default error pages.
+      error_page 403 @403;
+      error_page 502 @502;
 
       # Capture identity from the auth subrequest and forward it to the backend.
-      # These auth_request_set directives must live here (alongside auth_request),
-      # not at server scope — otherwise nginx evaluates them for error_page
-      # locations like @502 that have no auth subrequest, which breaks those
-      # internal redirects.
       auth_request_set $auth_user   $upstream_http_x_auth_request_user;
       auth_request_set $auth_email  $upstream_http_x_auth_request_email;
       auth_request_set $auth_groups $upstream_http_x_auth_request_groups;

From 2db1683d6dcd6c9c7237091bc527a3937bb90054 Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Tue, 16 Jun 2026 17:37:30 -0400
Subject: [PATCH 09/20] Consolidate error_page to a single server-scope block
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Declare every error_page mapping once, at server scope, and remove all
location-level error_page directives. Previously the protected location /
re-declared error_page 401/403/502 (because a location-level error_page
replaces, rather than merges with, the inherited list) and the no-URL
location / declared error_page 503 — three separate places per server.

Moving error_page 401 = @oauth2_signin and error_page 503 =
@auth_unavailable to server scope keeps them working (verified with a
local nginx repro: unauth -> sign-in redirect, auth + dead backend ->
custom @502, no-URL -> @auth_unavailable) while leaving exactly one
error_page block per server and nothing to re-declare.

Refs #348
---
 create-a-container/views/nginx-conf.ejs | 20 ++++++++++----------
 1 file changed, 10 insertions(+), 10 deletions(-)

diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs
index a0322206..c25ead58 100644
--- a/create-a-container/views/nginx-conf.ejs
+++ b/create-a-container/views/nginx-conf.ejs
@@ -216,7 +216,17 @@ http {
     add_header X-XSS-Protection "1; mode=block" always;
     add_header Alt-Svc 'h3=":443"; ma=86400' always;
     
+    # All error_page mappings for this server are declared here, once. Keep
+    # them at server scope (not inside a location) so a location-level
+    # error_page can never replace the inherited list — see the auth-enabled
+    # `location /` below, which relies on inheriting these.
     error_page 403 @403;
+    error_page 502 @502;
+    <%_ if (authRequired && authServer) { _%>
+    error_page 401 = @oauth2_signin;
+    <%_ } else if (authRequired && !authServer) { _%>
+    error_page 503 @auth_unavailable;
+    <%_ } _%>
 
     location @403 {
       rewrite ^ /403.html break;
@@ -226,8 +236,6 @@ http {
       proxy_pass http://error_pages;
     }
 
-    error_page 502 @502;
-
     location @502 {
       rewrite ^ /502.html break;
       proxy_method GET;
@@ -280,7 +288,6 @@ http {
     }
 
     location / {
-      error_page 503 @auth_unavailable;
       return 503;
     }
     <%_ } else { _%>
@@ -288,13 +295,6 @@ http {
     location / {
       <%_ if (authRequired && authServer) { _%>
       auth_request /oauth2/auth;
-      error_page 401 = @oauth2_signin;
-      # A location-level error_page replaces (does not merge with) the
-      # server-level error_page list, so the @403/@502 handlers defined above
-      # must be re-declared here or this location would fall back to nginx's
-      # default error pages.
-      error_page 403 @403;
-      error_page 502 @502;
 
       # Capture identity from the auth subrequest and forward it to the backend.
       auth_request_set $auth_user   $upstream_http_x_auth_request_user;

From 407ee8de67e917d1597dea7bbd1ae9c74027c48b Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Tue, 16 Jun 2026 17:46:08 -0400
Subject: [PATCH 10/20] docs: drop stale oauth2-proxy reverse-proxy framing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Now that NGINX sends requests directly to oauth2-proxy, the docs no
longer need to contrast with the old reverse-proxy/multi-hop setup.
Remove the "single hop", "no second hop to clobber headers", and
"you do not need --reverse-proxy / --real-ip-from / X-Forwarded-*"
language, which is only meaningful to someone who knew the previous
arrangement. The sections now describe the direct setup positively.

(General "nginx is the reverse proxy for containers" references are
unchanged — that architecture is unchanged.)

Refs #348
---
 .../docs/admins/core-concepts/external-domains.md          | 7 ++-----
 .../docs/developers/system-architecture.md                 | 2 +-
 2 files changed, 3 insertions(+), 6 deletions(-)

diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index b5707d57..f45ff590 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -117,7 +117,7 @@ The manager does **not** provide authentication itself — you must deploy and c
 
 ### Configuring oauth2-proxy
 
-Run oauth2-proxy as a standalone process listening on its own address, and set the domain's **oauth2-proxy URL** to that address (e.g. `http://127.0.0.1:4180`). NGINX proxies the whole `/oauth2/*` path on each protected service straight to that address in a **single hop**, so to the browser the OAuth2 endpoints appear under the app's own hostname (`app.example.com/oauth2/...`) while actually being served by oauth2-proxy.
+Run oauth2-proxy as a standalone process listening on its own address, and set the domain's **oauth2-proxy URL** to that address (e.g. `http://127.0.0.1:4180`). NGINX proxies the whole `/oauth2/*` path on each protected service to that address, so to the browser the OAuth2 endpoints appear under the app's own hostname (`app.example.com/oauth2/...`) while actually being served by oauth2-proxy.
 
 1. **Run oauth2-proxy** on a fixed address reachable from the NGINX host (loopback if co-located, e.g. `http://127.0.0.1:4180`, or a private host/port).
 2. **Point protected domains at it.** Set the **oauth2-proxy URL** on each external domain whose services should require auth.
@@ -126,15 +126,13 @@ Run oauth2-proxy as a standalone process listening on its own address, and set t
 A single oauth2-proxy instance can serve many services this way — they all proxy `/oauth2/*` to the same address. Because NGINX passes each app's own `Host` through, oauth2-proxy builds redirect URIs and cookies against the correct app hostname without any extra configuration.
 
 !!! note "Putting oauth2-proxy behind the same load balancer"
-    oauth2-proxy does **not** need to be on the NGINX host. If you want it to live behind the same load-balancer IP, expose its port with an L4 (TCP) passthrough — e.g. a **transport service**, which NGINX serves via its `stream {}` block — and point the **oauth2-proxy URL** at that address. The `/oauth2/*` traffic still reaches oauth2-proxy in a single hop, so none of the header/`--reverse-proxy` handling below is required.
+    oauth2-proxy does **not** need to be on the NGINX host. If you want it to live behind the same load-balancer IP, expose its port with an L4 (TCP) passthrough — e.g. a **transport service**, which NGINX serves via its `stream {}` block — and point the **oauth2-proxy URL** at that address.
 
 Run oauth2-proxy with at least:
 
 - `--set-xauthrequest=true` — so identity is returned in `X-Auth-Request-*` response headers (forwarded to the backend).
 - `--pass-access-token=true` *(optional)* — to forward the access token as `X-Access-Token`.
 
-Because NGINX proxies straight to oauth2-proxy (nothing sits in front of it from its point of view), you do **not** need `--reverse-proxy`, `--real-ip-from`, or any `X-Forwarded-*` headers.
-
 !!! warning "HTTPS scheme"
     oauth2-proxy builds its `redirect_uri` and secure cookies from the scheme of the connection it receives. The scheme is whatever you put in the **oauth2-proxy URL**:
 
@@ -189,7 +187,6 @@ sequenceDiagram
     else 401 (unauthenticated)
         OAuth2Proxy-->>NGINX: 401
         NGINX-->>Client: 302 → app.example.com/oauth2/sign_in?rd=https://app.example.com/page
-        Note over Client,OAuth2Proxy: /oauth2/* is proxied to oauth2-proxy in one hop
     end
 ```
 
diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md
index b80be4bd..34a81526 100644
--- a/mie-opensource-landing/docs/developers/system-architecture.md
+++ b/mie-opensource-landing/docs/developers/system-architecture.md
@@ -148,7 +148,7 @@ sequenceDiagram
 
 When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests against an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) process before proxying. The domain's `authServer` is the address of that process, e.g. `http://127.0.0.1:4180` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). The manager itself no longer provides forward-auth — administrators run and configure oauth2-proxy.
 
-NGINX proxies the whole `/oauth2/*` subtree (and the `auth_request` check at `/oauth2/auth`) straight to `authServer` in a **single hop**, passing the app's own `Host` through. Because oauth2-proxy terminates these requests directly, it builds redirect URIs and cookies against the correct app hostname and needs no `--reverse-proxy` / `X-Forwarded-*` handling. If the admin wants oauth2-proxy behind the same load-balancer IP, they expose its port via an L4 (`stream {}`) passthrough; it is never fronted by a generated `server` block, so there is no second hop to clobber headers.
+NGINX proxies the whole `/oauth2/*` subtree (and the `auth_request` check at `/oauth2/auth`) to `authServer`, passing the app's own `Host` through. oauth2-proxy terminates those requests itself and builds redirect URIs and cookies against the correct app hostname. If the admin wants oauth2-proxy behind the same load-balancer IP, they expose its port via an L4 (`stream {}`) passthrough.
 
 ```mermaid
 sequenceDiagram

From f8d4e4e02970ece0328d55c1d892defc4a640287 Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Thu, 25 Jun 2026 12:14:24 -0400
Subject: [PATCH 11/20] simplify nginx config, pass preferred username to
 backends using auth

---
 create-a-container/views/nginx-conf.ejs | 248 ++++++++----------------
 1 file changed, 86 insertions(+), 162 deletions(-)

diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs
index c25ead58..547653fe 100644
--- a/create-a-container/views/nginx-conf.ejs
+++ b/create-a-container/views/nginx-conf.ejs
@@ -33,7 +33,7 @@ http {
 
   server_names_hash_bucket_size 128;
 
-  # Cache zone for auth_request subrequests, keyed on credentials.
+  <%_ /* Cache zone for auth_request subrequests, keyed on credentials. */ -%>
   proxy_cache_path /var/cache/nginx/auth_cache levels=1:2 keys_zone=auth_cache:1m
                    max_size=10m inactive=5m;
 
@@ -41,10 +41,29 @@ http {
   modsecurity_rules_file /etc/nginx/modsecurity_includes.conf;
   modsecurity_transaction_id "$request_id";
 
-  # Internal error page server on a unix socket. Named locations proxy here
-  # with proxy_method GET so that NGINX's static file module will serve the
-  # HTML regardless of the original request method (POST, PUT, etc.).
-  # ModSecurity is disabled to prevent re-evaluation of the original request.
+  <%_ /* Modern TLS configuration */ -%>
+  ssl_protocols TLSv1.2 TLSv1.3;
+  ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
+  ssl_prefer_server_ciphers off;
+
+  <%_ /* SSL session optimization */ -%>
+  ssl_session_cache shared:SSL:10m;
+  ssl_session_timeout 10m;
+  ssl_session_tickets off;
+
+  <%_ /* Security headers (a location-level add_header would replace these). */ -%>
+  add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+  add_header X-Frame-Options "SAMEORIGIN" always;
+  add_header X-Content-Type-Options "nosniff" always;
+  add_header X-XSS-Protection "1; mode=block" always;
+  add_header Alt-Svc 'h3=":443"; ma=86400' always;
+
+  <%_ /*
+    Internal error page server on a unix socket. Named locations proxy here
+    with proxy_method GET so that NGINX's static file module will serve the
+    HTML regardless of the original request method (POST, PUT, etc.).
+    ModSecurity is disabled to prevent re-evaluation of the original request.
+  */ -%>
   upstream error_pages {
     server unix:/run/nginx-error-pages.sock;
   }
@@ -79,29 +98,11 @@ http {
 
     server_name _;
 
-    # SSL certificates
+    <%_ /* SSL certificates */ -%>
     ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
     ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
 
-    # Modern TLS configuration
-    ssl_protocols TLSv1.2 TLSv1.3;
-    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
-    ssl_prefer_server_ciphers off;
-
-    # SSL session optimization
-    ssl_session_cache shared:SSL:10m;
-    ssl_session_timeout 10m;
-    ssl_session_tickets off;
-
-    # Security headers
-    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
-    add_header X-Frame-Options "SAMEORIGIN" always;
-    add_header X-Content-Type-Options "nosniff" always;
-    add_header X-XSS-Protection "1; mode=block" always;
-    add_header Alt-Svc 'h3=":443"; ma=86400' always;
-
     error_page 403 @403;
-
     location @403 {
       rewrite ^ /403.html break;
       proxy_method GET;
@@ -110,9 +111,16 @@ http {
       proxy_pass http://error_pages;
     }
 
-    <%_ if (httpServices.length === 0) { _%>
-    error_page 502 @502;
+    error_page 404 @404;
+    location @404 {
+      rewrite ^ /404.html break;
+      proxy_method GET;
+      proxy_pass_request_body off;
+      proxy_set_header Content-Length "";
+      proxy_pass http://error_pages;
+    }
 
+    error_page 502 @502;
     location @502 {
       rewrite ^ /502.html break;
       proxy_method GET;
@@ -121,11 +129,12 @@ http {
       proxy_pass http://error_pages;
     }
 
+    <%_ if (httpServices.length === 0) { _%>
     location / {
       proxy_pass http://localhost:3000;
       proxy_http_version 1.1;
 
-      # Proxy headers
+      <%_ /* Proxy headers */ -%>
       proxy_set_header Host $host;
       proxy_set_header X-Real-IP $remote_addr;
       proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -133,51 +142,23 @@ http {
       proxy_set_header X-Forwarded-Host $host;
       proxy_set_header X-Forwarded-Port $server_port;
 
-      # WebSocket support
+      <%_ /* WebSocket support */ -%>
       proxy_set_header Upgrade $http_upgrade;
       proxy_set_header Connection "upgrade";
 
-      # Timeouts
+      <%_ /* Timeouts */ -%>
       proxy_connect_timeout 60s;
       proxy_send_timeout 60s;
       proxy_read_timeout 60s;
 
-      # Buffering (disable for SSE/streaming)
+      <%_ /* Buffering (disable for SSE/streaming) */ -%>
       proxy_buffering off;
       proxy_request_buffering off;
 
-      # Allow large uploads
+      <%_ /* Allow large uploads */ -%>
       client_max_body_size 2G;
     }
     <%_ } else { _%>
-    error_page 403 @403;
-    error_page 404 @404;
-    error_page 502 @502;
-
-    location @403 {
-      rewrite ^ /403.html break;
-      proxy_method GET;
-      proxy_pass_request_body off;
-      proxy_set_header Content-Length "";
-      proxy_pass http://error_pages;
-    }
-
-    location @404 {
-      rewrite ^ /404.html break;
-      proxy_method GET;
-      proxy_pass_request_body off;
-      proxy_set_header Content-Length "";
-      proxy_pass http://error_pages;
-    }
-
-    location @502 {
-      rewrite ^ /502.html break;
-      proxy_method GET;
-      proxy_pass_request_body off;
-      proxy_set_header Content-Length "";
-      proxy_pass http://error_pages;
-    }
-
     return 404;
     <%_ } _%>
   }
@@ -195,39 +176,11 @@ http {
     
     server_name <%= service.httpService.externalHostname %>.<%= service.httpService.externalDomain.name %>;
   
-    # SSL certificates
+    <%_ /* SSL certificates */ -%>
     ssl_certificate /etc/ssl/certs/<%= service.httpService.externalDomain.name %>.crt;
     ssl_certificate_key /etc/ssl/private/<%= service.httpService.externalDomain.name %>.key;
-    
-    # Modern TLS configuration
-    ssl_protocols TLSv1.2 TLSv1.3;
-    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
-    ssl_prefer_server_ciphers off;
-    
-    # SSL session optimization
-    ssl_session_cache shared:SSL:10m;
-    ssl_session_timeout 10m;
-    ssl_session_tickets off;
-    
-    # Security headers
-    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
-    add_header X-Frame-Options "SAMEORIGIN" always;
-    add_header X-Content-Type-Options "nosniff" always;
-    add_header X-XSS-Protection "1; mode=block" always;
-    add_header Alt-Svc 'h3=":443"; ma=86400' always;
-    
-    # All error_page mappings for this server are declared here, once. Keep
-    # them at server scope (not inside a location) so a location-level
-    # error_page can never replace the inherited list — see the auth-enabled
-    # `location /` below, which relies on inheriting these.
-    error_page 403 @403;
-    error_page 502 @502;
-    <%_ if (authRequired && authServer) { _%>
-    error_page 401 = @oauth2_signin;
-    <%_ } else if (authRequired && !authServer) { _%>
-    error_page 503 @auth_unavailable;
-    <%_ } _%>
 
+    error_page 403 @403;
     location @403 {
       rewrite ^ /403.html break;
       proxy_method GET;
@@ -236,6 +189,7 @@ http {
       proxy_pass http://error_pages;
     }
 
+    error_page 502 @502;
     location @502 {
       rewrite ^ /502.html break;
       proxy_method GET;
@@ -245,14 +199,14 @@ http {
     }
 
     <%_ if (authRequired && authServer) { _%>
-    # oauth2-proxy runs on its own address (this domain's "oauth2-proxy URL").
-    # We proxy the whole /oauth2/* subtree straight to it in a single hop and
-    # pass the app's own Host through, so oauth2-proxy builds redirect URIs and
-    # cookies against this app's hostname. Because it terminates these requests
-    # directly (no proxy in front of it from its point of view), oauth2-proxy does
-    # NOT need --reverse-proxy or any X-Forwarded-* headers. The request scheme is
-    # whatever <%= authServer %> uses; pair an http:// upstream with
-    # --force-https / --cookie-secure on oauth2-proxy if the browser is on HTTPS.
+    <%_ /*
+      oauth2-proxy runs on its own address (this domain's "oauth2-proxy URL").
+      We proxy the whole /oauth2/* subtree to it and pass the app's own Host
+      through, so oauth2-proxy builds redirect URIs and cookies against this
+      app's hostname. The request scheme is whatever the oauth2-proxy URL uses;
+      pair an http:// upstream with --force-https / --cookie-secure on
+      oauth2-proxy if the browser is on HTTPS.
+    */ -%>
     location /oauth2/ {
       proxy_pass <%= authServer %>;
       proxy_set_header Host $host;
@@ -262,7 +216,7 @@ http {
       internal;
       proxy_pass <%= authServer %>/oauth2/auth;
       proxy_set_header Host             $host;
-      # nginx auth_request includes headers but not the request body.
+      <%_ /* nginx auth_request includes headers but not the request body. */ -%>
       proxy_set_header Content-Length   "";
       proxy_pass_request_body           off;
 
@@ -272,13 +226,14 @@ http {
       proxy_cache_valid 401 30s;
     }
 
+    error_page 401 = @oauth2_signin;
     location @oauth2_signin {
       return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri;
     }
-
     <%_ } _%>
     <%_ if (authRequired && !authServer) { _%>
-    # Authentication required but no oauth2-proxy URL configured for this domain
+    <%_ /* Authentication required but no oauth2-proxy URL configured for this domain */ -%>
+    error_page 503 @auth_unavailable;
     location @auth_unavailable {
       rewrite ^ /auth-unavailable.html break;
       proxy_method GET;
@@ -291,26 +246,28 @@ http {
       return 503;
     }
     <%_ } else { _%>
-    # Proxy settings
+    <%_ /* Proxy settings */ -%>
     location / {
       <%_ if (authRequired && authServer) { _%>
       auth_request /oauth2/auth;
 
-      # Capture identity from the auth subrequest and forward it to the backend.
-      auth_request_set $auth_user   $upstream_http_x_auth_request_user;
-      auth_request_set $auth_email  $upstream_http_x_auth_request_email;
-      auth_request_set $auth_groups $upstream_http_x_auth_request_groups;
-      auth_request_set $auth_token  $upstream_http_x_auth_request_access_token;
-
-      proxy_set_header X-User         $auth_user;
-      proxy_set_header X-Email        $auth_email;
-      proxy_set_header X-Groups       $auth_groups;
-      proxy_set_header X-Access-Token $auth_token;
+      <%_ /* Capture identity from the auth subrequest and forward it to the backend. */ -%>
+      auth_request_set $auth_user               $upstream_http_x_auth_request_user;
+      auth_request_set $auth_groups             $upstream_http_x_auth_request_groups;
+      auth_request_set $auth_email              $upstream_http_x_auth_request_email;
+      auth_request_set $auth_preferred_username $upstream_http_x_auth_request_preferred_username;
+      auth_request_set $auth_token              $upstream_http_x_auth_request_access_token;
+
+      proxy_set_header X-User               $auth_user;
+      proxy_set_header X-Preferred-Username $auth_preferred_username;
+      proxy_set_header X-Groups             $auth_groups;
+      proxy_set_header X-Email              $auth_email;
+      proxy_set_header X-Access-Token       $auth_token;
       <%_ } _%>
       proxy_pass <%= service.httpService.backendProtocol %>://<%= service.Container.ipv4Address %>:<%= service.internalPort %>;
       proxy_http_version 1.1;
       
-      # Proxy headers
+      <%_ /* Proxy headers */ -%>
       proxy_set_header Host $host;
       proxy_set_header X-Real-IP $remote_addr;
       proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -318,28 +275,33 @@ http {
       proxy_set_header X-Forwarded-Host $host;
       proxy_set_header X-Forwarded-Port $server_port;
       
-      # WebSocket support
+      <%_ /* WebSocket support */ -%>
       proxy_set_header Upgrade $http_upgrade;
       proxy_set_header Connection "upgrade";
       
-      # Timeouts
+      <%_ /* Timeouts */ -%>
       proxy_connect_timeout 60s;
       proxy_send_timeout 60s;
       proxy_read_timeout 60s;
       
-      # Buffering (disable for SSE/streaming)
+      <%_ /* Buffering (disable for SSE/streaming) */ -%>
       proxy_buffering off;
       proxy_request_buffering off;
       
-      # Allow large uploads
+      <%_ /* Allow large uploads */ -%>
       client_max_body_size 2G;
+
+      <%_ /* Allow large responses */ -%>
+      proxy_buffer_size 16k;
+      proxy_buffers 4 32k;
+      proxy_busy_buffers_size 64k;
     }
     <%_ } _%>
   }
   <%_ }) _%>
 
   <%_ externalDomains.forEach((domain) => { _%>
-  # Wildcard server for *.<%= domain.name %> - returns 404
+  <%_ /* Wildcard server for the domain - returns 404 */ -%>
   server {
     listen 443 ssl;
     listen [::]:443 ssl;
@@ -350,29 +312,11 @@ http {
     
     server_name *.<%= domain.name %>;
   
-    # SSL certificates
+    <%_ /* SSL certificates */ -%>
     ssl_certificate /etc/ssl/certs/<%= domain.name %>.crt;
     ssl_certificate_key /etc/ssl/private/<%= domain.name %>.key;
-    
-    # Modern TLS configuration
-    ssl_protocols TLSv1.2 TLSv1.3;
-    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
-    ssl_prefer_server_ciphers off;
-    
-    # SSL session optimization
-    ssl_session_cache shared:SSL:10m;
-    ssl_session_timeout 10m;
-    ssl_session_tickets off;
-    
-    # Security headers
-    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
-    add_header X-Frame-Options "SAMEORIGIN" always;
-    add_header X-Content-Type-Options "nosniff" always;
-    add_header X-XSS-Protection "1; mode=block" always;
-    add_header Alt-Svc 'h3=":443"; ma=86400' always;
-    
-    error_page 403 @403;
 
+    error_page 403 @403;
     location @403 {
       rewrite ^ /403.html break;
       proxy_method GET;
@@ -382,7 +326,6 @@ http {
     }
 
     error_page 404 @404;
-
     location @404 {
       rewrite ^ /404.html break;
       proxy_method GET;
@@ -391,11 +334,11 @@ http {
       proxy_pass http://error_pages;
     }
 
-    # Return 404 for all requests
+    <%_ /* Return 404 for all requests */ -%>
     return 404;
   }
 
-  # Bare domain <%= domain.name %> - proxies to docs site
+  <%_ /* Bare domain - proxies to docs site */ -%>
   server {
     listen 443 ssl;
     listen [::]:443 ssl;
@@ -406,29 +349,11 @@ http {
     
     server_name <%= domain.name %>;
   
-    # SSL certificates
+    <%_ /* SSL certificates */ -%>
     ssl_certificate /etc/ssl/certs/<%= domain.name %>.crt;
     ssl_certificate_key /etc/ssl/private/<%= domain.name %>.key;
-    
-    # Modern TLS configuration
-    ssl_protocols TLSv1.2 TLSv1.3;
-    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
-    ssl_prefer_server_ciphers off;
-    
-    # SSL session optimization
-    ssl_session_cache shared:SSL:10m;
-    ssl_session_timeout 10m;
-    ssl_session_tickets off;
-    
-    # Security headers
-    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
-    add_header X-Frame-Options "SAMEORIGIN" always;
-    add_header X-Content-Type-Options "nosniff" always;
-    add_header X-XSS-Protection "1; mode=block" always;
-    add_header Alt-Svc 'h3=":443"; ma=86400' always;
-    
-    error_page 403 @403;
 
+    error_page 403 @403;
     location @403 {
       rewrite ^ /403.html break;
       proxy_method GET;
@@ -438,7 +363,6 @@ http {
     }
 
     error_page 502 @502;
-
     location @502 {
       rewrite ^ /502.html break;
       proxy_method GET;
@@ -447,7 +371,7 @@ http {
       proxy_pass http://error_pages;
     }
 
-    # Serve documentation site statically
+    <%_ /* Serve documentation site statically */ -%>
     location / {
       root /opt/opensource-server/mie-opensource-landing/site;
       index index.html;

From 3a69160a0ae8310e11ee3615d356019c05f26a5c Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Thu, 25 Jun 2026 13:02:50 -0400
Subject: [PATCH 12/20] docs: add "Adding Authentication" user guide for the
 auth header contract

Add a Users-section page describing how an app consumes the signed-in
user's identity behind Require-auth: the stable request headers (X-User,
X-Preferred-Username, X-Email, X-Groups, X-Access-Token), how to read and
verify the access-token JWT, and how a static/serverless frontend can call
/oauth2/userinfo. Documents X-Preferred-Username (added to the contract in
b7a4c75) and clarifies that X-User is a stable opaque id, not a display
name.

The detailed identity-header table moves out of the External Domains admin
page into this user-facing doc; admin/developer pages keep a short summary
and link to it. Registered in the nav.
---
 .../docs/admins/core-concepts/containers.md   |   2 +-
 .../admins/core-concepts/external-domains.md  |  11 +-
 .../docs/developers/system-architecture.md    |   2 +-
 .../docs/users/consuming-auth.md              | 150 ++++++++++++++++++
 mie-opensource-landing/zensical.toml          |   1 +
 5 files changed, 156 insertions(+), 10 deletions(-)
 create mode 100644 mie-opensource-landing/docs/users/consuming-auth.md

diff --git a/mie-opensource-landing/docs/admins/core-concepts/containers.md b/mie-opensource-landing/docs/admins/core-concepts/containers.md
index 04eceec8..42f37213 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/containers.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/containers.md
@@ -20,5 +20,5 @@ Users in the **ldapusers** group can SSH into any container using their cluster
 
 Users can expose HTTP services from containers using [external domains](external-domains.md). Services are automatically configured with SSL/TLS certificates, reverse proxy routing, and DNS records.
 
-HTTP services can optionally require authentication via the **Require auth** checkbox. When enabled, NGINX authenticates requests against the domain's [oauth2-proxy server](external-domains.md#authentication) before proxying. Authenticated requests include identity headers (`X-User`, `X-Email`, `X-Groups`) forwarded to the backend. See [External Domains — Authentication](external-domains.md#authentication) for configuration details.
+HTTP services can optionally require authentication via the **Require auth** checkbox. When enabled, NGINX authenticates requests against the domain's [oauth2-proxy server](external-domains.md#authentication) before proxying. Authenticated requests include identity headers (`X-User`, `X-Preferred-Username`, `X-Email`, `X-Groups`) forwarded to the backend — see [Adding Authentication](../../users/consuming-auth.md) for how apps consume them. See [External Domains — Authentication](external-domains.md#authentication) for configuration details.
 
diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index f45ff590..1e4f5348 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -155,14 +155,9 @@ See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oa
 
 ### Identity Headers
 
-When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Request-*` response headers and forwards them to the backend under a **stable header contract** (so the backend sees the same names regardless of the auth provider):
-
-| Header forwarded to backend | Source (oauth2-proxy response) |
-|-----------------------------|--------------------------------|
-| `X-User` | `X-Auth-Request-User` |
-| `X-Email` | `X-Auth-Request-Email` |
-| `X-Groups` | `X-Auth-Request-Groups` |
-| `X-Access-Token` | `X-Auth-Request-Access-Token` (with `--pass-access-token`) |
+When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Request-*` response headers and forwards the user's identity to the backend under a **stable header contract** (`X-User`, `X-Preferred-Username`, `X-Email`, `X-Groups`, and — with `--pass-access-token` — `X-Access-Token`).
+
+For the full header table and how applications consume the identity (server-side headers, verifying the access-token JWT, and the browser `/oauth2/userinfo` endpoint for static frontends), see [Adding Authentication](../../users/consuming-auth.md).
 
 ### Sharing one sign-in across subdomains
 
diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md
index 34a81526..cdc9f844 100644
--- a/mie-opensource-landing/docs/developers/system-architecture.md
+++ b/mie-opensource-landing/docs/developers/system-architecture.md
@@ -170,7 +170,7 @@ sequenceDiagram
     end
 ```
 
-NGINX captures identity headers from the oauth2-proxy subrequest (`X-Auth-Request-User`, `X-Auth-Request-Email`, `X-Auth-Request-Groups`, and the access token) and forwards them to the backend container via `proxy_set_header` under a stable contract (`X-User`, `X-Email`, `X-Groups`, `X-Access-Token`). This requires oauth2-proxy to run with `--set-xauthrequest` (and `--pass-access-token` for the token).
+NGINX captures identity headers from the oauth2-proxy subrequest (`X-Auth-Request-User`, `X-Auth-Request-Preferred-Username`, `X-Auth-Request-Email`, `X-Auth-Request-Groups`, and the access token) and forwards them to the backend container via `proxy_set_header` under a stable contract (`X-User`, `X-Preferred-Username`, `X-Email`, `X-Groups`, `X-Access-Token`). This requires oauth2-proxy to run with `--set-xauthrequest` (and `--pass-access-token` for the token). See [Adding Authentication](../users/consuming-auth.md) for how applications consume these.
 
 If `authRequired` is enabled but no `authServer` is configured on the domain, NGINX serves a 503 error page. (A malformed `authServer` cannot reach the template — it is rejected by the API on write.)
 
diff --git a/mie-opensource-landing/docs/users/consuming-auth.md b/mie-opensource-landing/docs/users/consuming-auth.md
new file mode 100644
index 00000000..e905eaeb
--- /dev/null
+++ b/mie-opensource-landing/docs/users/consuming-auth.md
@@ -0,0 +1,150 @@
+# Adding Authentication
+
+You can put a sign-in wall in front of any HTTP service so only authenticated users reach it — without writing login code yourself. Requests are authenticated by [oauth2-proxy](../admins/core-concepts/external-domains.md#authentication) before they ever reach your app; your app just reads who the user is.
+
+## Turn on authentication
+
+1. Make sure the external domain your service uses has an **oauth2-proxy URL** configured. This is an admin setup — if it isn't set, ask your environment administrator (see [External Domains — Authentication](../admins/core-concepts/external-domains.md#authentication)).
+2. Enable the **Require auth** checkbox on your HTTP service when [creating or editing the container](creating-containers/web-gui.md).
+
+That's it — unauthenticated visitors are now redirected to sign in. The rest of this page is about reading the signed-in user's identity in your app, in three ways:
+
+1. **Server-side apps** — read the identity headers NGINX adds to each proxied request.
+2. **Apps that need the raw token / extra claims** — read and (optionally) verify the access token.
+3. **Static / "serverless" frontends** — call oauth2-proxy's `/oauth2/userinfo` endpoint from the browser.
+
+## Identity headers (server-side)
+
+Every authenticated request arrives at your backend with a **stable set of headers** describing the signed-in user. The names are always the same, so your app can rely on them:
+
+| Header | Description |
+|--------|-------------|
+| `X-User` | Stable, unique user id (the identity provider's `sub` claim). Treat it as an opaque key — it isn't guaranteed to be human-readable. |
+| `X-Preferred-Username` | Human-friendly username (when the user has one) |
+| `X-Email` | User's email |
+| `X-Groups` | Comma-separated list of the user's groups |
+| `X-Access-Token` | The user's access token, for calling other APIs on their behalf (see below). May be absent depending on how the domain is configured. |
+
+!!! tip "Which header to use for what"
+    Use `X-User` as the **stable key** to identify a user in your database or logs — it never changes, even if they rename themselves or change email. To **display** a name, use `X-Preferred-Username` (falling back to `X-Email`); `X-User` may be an opaque id (such as a UUID) and isn't guaranteed to be meaningful to a person.
+
+Reading them is trivial — they are ordinary request headers. Example in Node/Express:
+
+```js
+app.get('/', (req, res) => {
+  const user = {
+    id: req.get('X-User'),
+    username: req.get('X-Preferred-Username'),
+    email: req.get('X-Email'),
+    groups: (req.get('X-Groups') || '').split(',').filter(Boolean),
+  };
+  res.json({ user });
+});
+```
+
+!!! warning "Trust boundary"
+    These headers are trustworthy because every request reaches your service through the authenticating proxy, which sets them (and overwrites any a client tries to send). For this to hold, don't expose your container on a separate route that bypasses the proxy, and don't forward these headers on to untrusted third parties.
+
+## Reading and verifying the access token
+
+If you need more than the identity headers above — for example to call another API on the user's behalf, or to read custom claims — use the access token from the `X-Access-Token` header. (If that header isn't present, access-token passthrough isn't enabled for your domain; ask your administrator.)
+
+Whether that token is a **JWT** depends on your identity provider:
+
+- **Providers that issue JWT access tokens** (Keycloak, Microsoft Entra ID, Auth0, etc.) — you can decode and verify it.
+- **Providers that issue opaque tokens** (e.g. plain Google OAuth) — the token is not a JWT; treat it as a bearer string and validate it by calling the provider's introspection/userinfo endpoint instead.
+
+### Decode (no verification)
+
+A JWT is three base64url segments (`header.payload.signature`). Decoding the payload gives you the claims, but **decoding is not verification** — never make an authorization decision on a decoded-but-unverified token that came from an untrusted source.
+
+```js
+function decodeJwtPayload(token) {
+  const payload = token.split('.')[1];
+  const json = Buffer.from(payload, 'base64url').toString('utf8');
+  return JSON.parse(json);
+}
+```
+
+### Verify (recommended)
+
+Verify the signature against your IdP's public keys (JWKS) and check the standard claims (`iss`, `aud`, `exp`). Use a maintained library rather than hand-rolling this.
+
+```js
+import { createRemoteJWKSet, jwtVerify } from 'jose';
+
+// Your IdP's JWKS endpoint, e.g. from its OIDC discovery document
+// (<issuer>/.well-known/openid-configuration -> "jwks_uri").
+const JWKS = createRemoteJWKSet(new URL(process.env.OIDC_JWKS_URI));
+
+async function verifyAccessToken(token) {
+  const { payload } = await jwtVerify(token, JWKS, {
+    issuer: process.env.OIDC_ISSUER,      // expected "iss"
+    audience: process.env.OIDC_AUDIENCE,  // expected "aud" (your client/app id)
+  });
+  return payload; // verified claims
+}
+```
+
+!!! tip
+    The values you need (`OIDC_ISSUER`, `OIDC_JWKS_URI`, `OIDC_AUDIENCE`) come from the same identity provider oauth2-proxy is configured against. Ask the administrator who set up the **oauth2-proxy URL** for the domain, or read them from the provider's discovery document at `<issuer>/.well-known/openid-configuration`.
+
+!!! warning
+    `X-Access-Token` is a credential. Do not log it, return it to the browser, or send it anywhere other than the API it is intended for.
+
+## Static / "serverless" frontends: `/oauth2/userinfo`
+
+A single-page app, static site, or any frontend with **no backend of its own** cannot read the identity headers (those are added between NGINX and a backend, not visible to the browser). Instead, oauth2-proxy exposes a JSON endpoint on the app's own origin:
+
+```
+GET https://app.example.com/oauth2/userinfo
+```
+
+Because `/oauth2/*` is served on your app's own hostname, the browser sends the session cookie automatically — just include credentials:
+
+```js
+const res = await fetch('/oauth2/userinfo', { credentials: 'include' });
+if (res.status === 401) {
+  // Not signed in — send the user through sign-in, then back here.
+  window.location.href =
+    '/oauth2/sign_in?rd=' + encodeURIComponent(window.location.href);
+} else {
+  const user = await res.json();
+  // { user, email, groups, preferredUsername, additionalClaims }
+  console.log(user.preferredUsername, user.email, user.groups);
+}
+```
+
+The response is JSON with this shape:
+
+```json
+{
+  "user": "a1b2c3d4",
+  "email": "jane@example.com",
+  "groups": ["developers", "admins"],
+  "preferredUsername": "jane",
+  "additionalClaims": {}
+}
+```
+
+| Field | Description |
+|-------|-------------|
+| `user` | Stable, unique user id (the provider's `sub` claim) — treat as an opaque key; not guaranteed to be human-readable |
+| `email` | User's email |
+| `groups` | Array of group names (omitted if none) |
+| `preferredUsername` | Human-friendly username (omitted if the user doesn't have one) |
+| `additionalClaims` | Any extra identity claims configured for your domain (omitted if none) |
+
+!!! warning "`/oauth2/userinfo` is for display, not authorization"
+    This endpoint reflects the browser's own session, so it is fine for personalizing the UI (showing a name, hiding admin links). It is **not** an authorization mechanism — a static frontend cannot keep secrets, and a determined user controls their own browser. Enforce access control on the **server** side: keep sensitive data behind a Require-auth backend (which reads the trusted identity headers) or behind an API that verifies the token.
+
+### Helpful oauth2-proxy endpoints from the browser
+
+| Endpoint | Purpose |
+|----------|---------|
+| `/oauth2/userinfo` | JSON identity of the current session (`401` if not signed in) |
+| `/oauth2/sign_in?rd=<return-url>` | Start sign-in, then return to `<return-url>` |
+| `/oauth2/sign_out?rd=<return-url>` | Clear the oauth2-proxy session cookie |
+
+!!! note
+    The `/oauth2` prefix is the default. If the administrator changed oauth2-proxy's `--proxy-prefix`, these paths change accordingly.
diff --git a/mie-opensource-landing/zensical.toml b/mie-opensource-landing/zensical.toml
index 85baa56b..e83a4f30 100644
--- a/mie-opensource-landing/zensical.toml
+++ b/mie-opensource-landing/zensical.toml
@@ -21,6 +21,7 @@ nav = [
         { "API Keys" = "users/creating-containers/api-keys.md" },
       ] },
       { "Monitoring Containers" = "users/monitoring-container.md" },
+      { "Adding Authentication" = "users/consuming-auth.md" },
       { "Launchpad" = [
         { "What is Launchpad" = "users/proxmox-launchpad/what-is-proxmox-launchpad.md" },
         { "Getting Started" = "users/proxmox-launchpad/getting-started.md" },

From 499296bd0564b908e630bdfbc36158f498a562ee Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Thu, 25 Jun 2026 13:03:02 -0400
Subject: [PATCH 13/20] Enlarge nginx header buffers for large JWT access
 tokens
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

oauth2-proxy returns the identity — including the access-token JWT, which
can be several KB — in the /oauth2/auth subrequest response headers. With
nginx's default proxy buffers, a token larger than ~4-8k overflows the
response-header buffer, so the auth subrequest fails (502) and the user
gets a 500. Reproduced: a 2k token works, an 8k token breaks.

Add headroom:
- large_client_header_buffers 8 16k (http) for big inbound cookies/tokens
- proxy_buffer_size 16k / proxy_buffers 8 16k on /oauth2/auth (the auth
  response carrying the token) and /oauth2/ (sign-in/callback Set-Cookie)

Verified 2k/4k/8k tokens pass and nginx -t succeeds. For tokens that
genuinely approach the buffer size, the Redis session store remains the
recommended fix.
---
 create-a-container/views/nginx-conf.ejs | 17 +++++++++++++++++
 1 file changed, 17 insertions(+)

diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs
index 547653fe..db703bca 100644
--- a/create-a-container/views/nginx-conf.ejs
+++ b/create-a-container/views/nginx-conf.ejs
@@ -33,6 +33,12 @@ http {
 
   server_names_hash_bucket_size 128;
 
+  <%_ /*
+    Headroom for large request headers — chiefly the oauth2-proxy session
+    cookie and JWT access tokens, which can run several KB. Default is 4 8k.
+  */ -%>
+  large_client_header_buffers 8 16k;
+
   <%_ /* Cache zone for auth_request subrequests, keyed on credentials. */ -%>
   proxy_cache_path /var/cache/nginx/auth_cache levels=1:2 keys_zone=auth_cache:1m
                    max_size=10m inactive=5m;
@@ -210,6 +216,9 @@ http {
     location /oauth2/ {
       proxy_pass <%= authServer %>;
       proxy_set_header Host $host;
+      <%_ /* Sign-in/callback responses can set large session cookies. */ -%>
+      proxy_buffer_size 16k;
+      proxy_buffers 8 16k;
     }
 
     location = /oauth2/auth {
@@ -220,6 +229,14 @@ http {
       proxy_set_header Content-Length   "";
       proxy_pass_request_body           off;
 
+      <%_ /*
+        oauth2-proxy's auth response carries the identity in headers, including
+        the access-token JWT (several KB). Enlarge the response-header buffers
+        so a big token doesn't make the auth subrequest fail (502 -> 500).
+      */ -%>
+      proxy_buffer_size 16k;
+      proxy_buffers 8 16k;
+
       proxy_cache auth_cache;
       proxy_cache_key "$http_cookie$http_authorization";
       proxy_cache_valid 202 5m;

From e590ad6383ea5f66451fa95b4b2f526eed38ba69 Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Thu, 25 Jun 2026 13:22:15 -0400
Subject: [PATCH 14/20] docs: add copy-paste oauth2-proxy config template

Consolidate the oauth2-proxy flags this manager's nginx integration
requires into a single oauth2-proxy.cfg the admin can copy and fill in
with their OIDC provider details (issuer, client id/secret, cookie
secret). Covers set_xauthrequest, pass_access_token, the static://202
upstream, force_https/cookie_secure for the http-upstream case, and a
Redis session store; with notes on per-app redirect_url derivation,
shared-subdomain cookie/whitelist domains, and why reverse_proxy must
stay off. Trims the now-duplicated flag snippets from the surrounding
warnings.
---
 .../admins/core-concepts/external-domains.md  | 65 ++++++++++++++-----
 1 file changed, 49 insertions(+), 16 deletions(-)

diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index 1e4f5348..db81b73d 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -128,30 +128,63 @@ A single oauth2-proxy instance can serve many services this way — they all pro
 !!! note "Putting oauth2-proxy behind the same load balancer"
     oauth2-proxy does **not** need to be on the NGINX host. If you want it to live behind the same load-balancer IP, expose its port with an L4 (TCP) passthrough — e.g. a **transport service**, which NGINX serves via its `stream {}` block — and point the **oauth2-proxy URL** at that address.
 
-Run oauth2-proxy with at least:
+This manager's nginx integration needs oauth2-proxy to return identity in `X-Auth-Request-*` headers (`set_xauthrequest`) and, if your apps use the access token, to expose it (`pass_access_token`). The example below sets these along with everything else required.
+
+### Example configuration
+
+A complete `oauth2-proxy.cfg` for use with this manager. Copy it, fill in the four OIDC values from your identity provider (and a generated cookie secret), and run `oauth2-proxy --config=/etc/oauth2-proxy.cfg`.
+
+```ini
+# --- listen address -------------------------------------------------------
+# This is what you put in the domain's "oauth2-proxy URL".
+http_address = "127.0.0.1:4180"
+
+# --- your identity provider (fill these in) -------------------------------
+provider      = "oidc"
+oidc_issuer_url = "https://idp.example.com/realms/your-realm"   # OIDC issuer
+client_id     = "REPLACE_ME"
+client_secret = "REPLACE_ME"
+# Generate with: openssl rand -base64 32
+cookie_secret = "REPLACE_ME"            # must be 16, 24, or 32 bytes
+# Which users may sign in. "*" allows any email the IdP returns.
+email_domains = ["*"]
+
+# --- required for this manager's nginx integration ------------------------
+# Return identity in X-Auth-Request-* headers (nginx forwards them to your app).
+set_xauthrequest = true
+# Also expose the access token (drop this if your app doesn't need it).
+pass_access_token = true
+# oauth2-proxy only answers /oauth2/* here; nginx serves the app itself.
+upstreams = ["static://202"]
+# The browser is on HTTPS even though nginx talks to us over plain HTTP,
+# so force https:// redirect URLs and Secure cookies.
+force_https  = true
+cookie_secure = true
+
+# --- recommended ----------------------------------------------------------
+# Keep the session cookie small (see "Large session cookies" below).
+session_store_type   = "redis"
+redis_connection_url = "redis://127.0.0.1:6379"
+```
+
+!!! note "Multiple apps, one oauth2-proxy"
+    A single instance can serve every protected service. Leave `redirect_url` unset — oauth2-proxy derives the callback per request as `https://<requested-host>/oauth2/callback`, so each app gets the right one. Register `https://<app-host>/oauth2/callback` as a redirect URI for **each** app in your IdP. If your protected services span multiple subdomains and you want one shared sign-in, also add `cookie_domains = [".example.com"]` and `whitelist_domains = [".example.com"]`.
 
-- `--set-xauthrequest=true` — so identity is returned in `X-Auth-Request-*` response headers (forwarded to the backend).
-- `--pass-access-token=true` *(optional)* — to forward the access token as `X-Access-Token`.
+!!! warning "Do not set `reverse_proxy`"
+    Because nginx proxies straight to oauth2-proxy (nothing sits in front of it from its point of view), leave `reverse_proxy` off. Enabling it makes oauth2-proxy trust `X-Forwarded-*` headers, which this integration neither sends nor needs.
 
 !!! warning "HTTPS scheme"
-    oauth2-proxy builds its `redirect_uri` and secure cookies from the scheme of the connection it receives. The scheme is whatever you put in the **oauth2-proxy URL**:
+    oauth2-proxy builds its `redirect_uri` and secure cookies from the scheme of the connection it receives, which is whatever scheme you put in the **oauth2-proxy URL**:
 
-    - **`http://…` upstream** (most common, e.g. `http://127.0.0.1:4180`): oauth2-proxy sees a plain-HTTP connection. Run it with `--force-https=true` and `--cookie-secure=true` so it still emits `https://` redirect URIs and secure cookies for HTTPS browsers.
-    - **`https://…` upstream**: terminate TLS on the oauth2-proxy listener; oauth2-proxy infers HTTPS from the connection directly.
+    - **`http://…` upstream** (most common, e.g. `http://127.0.0.1:4180`): oauth2-proxy sees a plain-HTTP connection, so the example config sets `force_https = true` and `cookie_secure = true` to still emit `https://` redirect URIs and Secure cookies for HTTPS browsers.
+    - **`https://…` upstream**: terminate TLS on the oauth2-proxy listener instead (`tls_cert_file` / `tls_key_file`); oauth2-proxy then infers HTTPS from the connection and you can drop `force_https`.
 
 See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/) for full configuration details.
 
 !!! warning "Large session cookies"
-    With the default **cookie** session store, oauth2-proxy packs the entire encrypted session (access, refresh, and ID tokens plus claims) into the `_oauth2_proxy` cookie, sent on every request. This easily exceeds 4&nbsp;KB and can trip NGINX header-buffer limits (e.g. a `502` on the `/oauth2/callback` response).
-
-    Use the **Redis** session store so only a small ticket is stored in the cookie:
-
-    ```
-    --session-store-type=redis
-    --redis-connection-url=redis://<host>:6379
-    ```
+    With the default **cookie** session store, oauth2-proxy packs the entire encrypted session (access, refresh, and ID tokens plus claims) into the `_oauth2_proxy` cookie, sent on every request. This easily exceeds 4&nbsp;KB and can trip NGINX header-buffer limits (e.g. a `502` on the `/oauth2/callback` response). The example config above avoids this by using the **Redis** session store (`session_store_type = "redis"`), which keeps only a small ticket in the cookie.
 
-    If you cannot run Redis, reduce what the cookie carries instead: drop `--pass-access-token` / `--set-authorization-header` if you do not need the token downstream, and request only the scopes you use. See the [session storage docs](https://oauth2-proxy.github.io/oauth2-proxy/configuration/session_storage/).
+    If you cannot run Redis, reduce what the cookie carries instead: drop `pass_access_token` if your app doesn't need the token, and request only the scopes you use. See the [session storage docs](https://oauth2-proxy.github.io/oauth2-proxy/configuration/session_storage/).
 
 ### Identity Headers
 
@@ -161,7 +194,7 @@ For the full header table and how applications consume the identity (server-side
 
 ### Sharing one sign-in across subdomains
 
-Because `/oauth2/*` is served on each app's own hostname, the oauth2-proxy cookie is scoped to that host by default. To share a single sign-in across multiple subdomains, run oauth2-proxy with `--cookie-domain=.example.com` and `--whitelist-domain=.example.com`, and make the protected services subdomains of the same parent domain.
+Because `/oauth2/*` is served on each app's own hostname, the oauth2-proxy cookie is scoped to that host by default. To share a single sign-in across multiple subdomains, add `cookie_domains = [".example.com"]` and `whitelist_domains = [".example.com"]` to the config, and make the protected services subdomains of the same parent domain.
 
 ### Flow
 

From 75e9e6f56a27c9d950766dd8a225e32411a7b572 Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Thu, 25 Jun 2026 14:16:02 -0400
Subject: [PATCH 15/20] docs: lead auth setup with the config; highlight it as
 TOML
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Restructure the Authentication section to open with the oauth2-proxy
config block (and the two setup steps), then follow with a "How it works"
explanation and the option-detail admonitions. Switch the fence from ini
to toml — oauth2-proxy's config is TOML, and the toml lexer highlights
the quoted strings, inline comments, and arrays correctly where ini did
not.
---
 .../admins/core-concepts/external-domains.md  | 69 +++++++++----------
 1 file changed, 31 insertions(+), 38 deletions(-)

diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index db81b73d..1f4fb347 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -117,58 +117,51 @@ The manager does **not** provide authentication itself — you must deploy and c
 
 ### Configuring oauth2-proxy
 
-Run oauth2-proxy as a standalone process listening on its own address, and set the domain's **oauth2-proxy URL** to that address (e.g. `http://127.0.0.1:4180`). NGINX proxies the whole `/oauth2/*` path on each protected service to that address, so to the browser the OAuth2 endpoints appear under the app's own hostname (`app.example.com/oauth2/...`) while actually being served by oauth2-proxy.
+Run oauth2-proxy with a config like the one below, then:
 
-1. **Run oauth2-proxy** on a fixed address reachable from the NGINX host (loopback if co-located, e.g. `http://127.0.0.1:4180`, or a private host/port).
-2. **Point protected domains at it.** Set the **oauth2-proxy URL** on each external domain whose services should require auth.
-3. **Enable Require auth** on the individual services you want protected.
+1. **Point protected domains at it.** Set the **oauth2-proxy URL** on each external domain whose services should require auth to the address from `http_address` (e.g. `http://127.0.0.1:4180`).
+2. **Enable Require auth** on the individual services you want protected.
 
-A single oauth2-proxy instance can serve many services this way — they all proxy `/oauth2/*` to the same address. Because NGINX passes each app's own `Host` through, oauth2-proxy builds redirect URIs and cookies against the correct app hostname without any extra configuration.
+Copy this `oauth2-proxy.cfg`, fill in the four OIDC values from your identity provider plus a generated cookie secret, and run `oauth2-proxy --config=/etc/oauth2-proxy.cfg`:
 
-!!! note "Putting oauth2-proxy behind the same load balancer"
-    oauth2-proxy does **not** need to be on the NGINX host. If you want it to live behind the same load-balancer IP, expose its port with an L4 (TCP) passthrough — e.g. a **transport service**, which NGINX serves via its `stream {}` block — and point the **oauth2-proxy URL** at that address.
-
-This manager's nginx integration needs oauth2-proxy to return identity in `X-Auth-Request-*` headers (`set_xauthrequest`) and, if your apps use the access token, to expose it (`pass_access_token`). The example below sets these along with everything else required.
-
-### Example configuration
-
-A complete `oauth2-proxy.cfg` for use with this manager. Copy it, fill in the four OIDC values from your identity provider (and a generated cookie secret), and run `oauth2-proxy --config=/etc/oauth2-proxy.cfg`.
-
-```ini
+```toml
 # --- listen address -------------------------------------------------------
 # This is what you put in the domain's "oauth2-proxy URL".
 http_address = "127.0.0.1:4180"
 
 # --- your identity provider (fill these in) -------------------------------
-provider      = "oidc"
-oidc_issuer_url = "https://idp.example.com/realms/your-realm"   # OIDC issuer
-client_id     = "REPLACE_ME"
+provider = "oidc"
+oidc_issuer_url = "https://idp.example.com/realms/your-realm"
+client_id = "REPLACE_ME"
 client_secret = "REPLACE_ME"
-# Generate with: openssl rand -base64 32
-cookie_secret = "REPLACE_ME"            # must be 16, 24, or 32 bytes
-# Which users may sign in. "*" allows any email the IdP returns.
-email_domains = ["*"]
+cookie_secret = "REPLACE_ME"   # 16, 24, or 32 bytes; generate: openssl rand -base64 32
+email_domains = ["*"]          # which users may sign in; "*" = any email the IdP returns
 
 # --- required for this manager's nginx integration ------------------------
-# Return identity in X-Auth-Request-* headers (nginx forwards them to your app).
-set_xauthrequest = true
-# Also expose the access token (drop this if your app doesn't need it).
-pass_access_token = true
-# oauth2-proxy only answers /oauth2/* here; nginx serves the app itself.
-upstreams = ["static://202"]
-# The browser is on HTTPS even though nginx talks to us over plain HTTP,
-# so force https:// redirect URLs and Secure cookies.
-force_https  = true
+set_xauthrequest = true        # return identity in X-Auth-Request-* headers
+pass_access_token = true       # also expose the access token (drop if unused)
+upstreams = ["static://202"]   # oauth2-proxy only answers /oauth2/*; nginx serves the app
+force_https = true             # browser is HTTPS even though nginx talks to us over HTTP
 cookie_secure = true
 
 # --- recommended ----------------------------------------------------------
-# Keep the session cookie small (see "Large session cookies" below).
-session_store_type   = "redis"
+session_store_type = "redis"   # keep the session cookie small (see below)
 redis_connection_url = "redis://127.0.0.1:6379"
 ```
 
+That's the whole setup. The rest of this section explains how it fits together and the options worth knowing about.
+
+### How it works
+
+oauth2-proxy runs as a standalone process listening on its own address (`http_address`). NGINX proxies the whole `/oauth2/*` path on each protected service straight to that address, so to the browser the OAuth2 endpoints appear under the app's own hostname (`app.example.com/oauth2/...`) while actually being served by oauth2-proxy.
+
+A single instance can serve many services this way — they all proxy `/oauth2/*` to the same address. Because NGINX passes each app's own `Host` through, oauth2-proxy builds redirect URIs and cookies against the correct app hostname without any extra configuration.
+
+!!! note "Putting oauth2-proxy behind the same load balancer"
+    oauth2-proxy does **not** need to be on the NGINX host. If you want it to live behind the same load-balancer IP, expose its port with an L4 (TCP) passthrough — e.g. a **transport service**, which NGINX serves via its `stream {}` block — and point the **oauth2-proxy URL** at that address.
+
 !!! note "Multiple apps, one oauth2-proxy"
-    A single instance can serve every protected service. Leave `redirect_url` unset — oauth2-proxy derives the callback per request as `https://<requested-host>/oauth2/callback`, so each app gets the right one. Register `https://<app-host>/oauth2/callback` as a redirect URI for **each** app in your IdP. If your protected services span multiple subdomains and you want one shared sign-in, also add `cookie_domains = [".example.com"]` and `whitelist_domains = [".example.com"]`.
+    Leave `redirect_url` unset — oauth2-proxy derives the callback per request as `https://<requested-host>/oauth2/callback`, so each app gets the right one. Register `https://<app-host>/oauth2/callback` as a redirect URI for **each** app in your IdP. If your protected services span multiple subdomains and you want one shared sign-in, also add `cookie_domains = [".example.com"]` and `whitelist_domains = [".example.com"]`.
 
 !!! warning "Do not set `reverse_proxy`"
     Because nginx proxies straight to oauth2-proxy (nothing sits in front of it from its point of view), leave `reverse_proxy` off. Enabling it makes oauth2-proxy trust `X-Forwarded-*` headers, which this integration neither sends nor needs.
@@ -176,16 +169,16 @@ redis_connection_url = "redis://127.0.0.1:6379"
 !!! warning "HTTPS scheme"
     oauth2-proxy builds its `redirect_uri` and secure cookies from the scheme of the connection it receives, which is whatever scheme you put in the **oauth2-proxy URL**:
 
-    - **`http://…` upstream** (most common, e.g. `http://127.0.0.1:4180`): oauth2-proxy sees a plain-HTTP connection, so the example config sets `force_https = true` and `cookie_secure = true` to still emit `https://` redirect URIs and Secure cookies for HTTPS browsers.
+    - **`http://…` upstream** (most common, e.g. `http://127.0.0.1:4180`): oauth2-proxy sees a plain-HTTP connection, so the config sets `force_https = true` and `cookie_secure = true` to still emit `https://` redirect URIs and Secure cookies for HTTPS browsers.
     - **`https://…` upstream**: terminate TLS on the oauth2-proxy listener instead (`tls_cert_file` / `tls_key_file`); oauth2-proxy then infers HTTPS from the connection and you can drop `force_https`.
 
-See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/) for full configuration details.
-
 !!! warning "Large session cookies"
-    With the default **cookie** session store, oauth2-proxy packs the entire encrypted session (access, refresh, and ID tokens plus claims) into the `_oauth2_proxy` cookie, sent on every request. This easily exceeds 4&nbsp;KB and can trip NGINX header-buffer limits (e.g. a `502` on the `/oauth2/callback` response). The example config above avoids this by using the **Redis** session store (`session_store_type = "redis"`), which keeps only a small ticket in the cookie.
+    With the default **cookie** session store, oauth2-proxy packs the entire encrypted session (access, refresh, and ID tokens plus claims) into the `_oauth2_proxy` cookie, sent on every request. This easily exceeds 4&nbsp;KB and can trip NGINX header-buffer limits (e.g. a `502` on the `/oauth2/callback` response). The config above avoids this with the **Redis** session store (`session_store_type = "redis"`), which keeps only a small ticket in the cookie.
 
     If you cannot run Redis, reduce what the cookie carries instead: drop `pass_access_token` if your app doesn't need the token, and request only the scopes you use. See the [session storage docs](https://oauth2-proxy.github.io/oauth2-proxy/configuration/session_storage/).
 
+See the [oauth2-proxy NGINX integration guide](https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/) for full configuration details.
+
 ### Identity Headers
 
 When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Request-*` response headers and forwards the user's identity to the backend under a **stable header contract** (`X-User`, `X-Preferred-Username`, `X-Email`, `X-Groups`, and — with `--pass-access-token` — `X-Access-Token`).

From 04bbbd1380d54b90ff999d1a90f0fa308b6504dd Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Thu, 25 Jun 2026 14:25:57 -0400
Subject: [PATCH 16/20] docs: drop upstreams from oauth2-proxy config, add
 tested options
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

upstreams isn't required in auth_request-only mode — oauth2-proxy answers
/oauth2/* directly and nginx serves the app, so there's nothing to
upstream (validation allows an empty list). Remove it.

Add the options used while testing this PR: code_challenge_method=S256
(PKCE, recommended) and skip_provider_button. Include
insecure_oidc_allow_unverified_email commented out — it's a security
relaxation that some IdPs need in dev but shouldn't be on by default.
---
 .../docs/admins/core-concepts/external-domains.md           | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index 1f4fb347..26683ed7 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -136,17 +136,21 @@ client_id = "REPLACE_ME"
 client_secret = "REPLACE_ME"
 cookie_secret = "REPLACE_ME"   # 16, 24, or 32 bytes; generate: openssl rand -base64 32
 email_domains = ["*"]          # which users may sign in; "*" = any email the IdP returns
+code_challenge_method = "S256" # use PKCE (recommended)
 
 # --- required for this manager's nginx integration ------------------------
 set_xauthrequest = true        # return identity in X-Auth-Request-* headers
 pass_access_token = true       # also expose the access token (drop if unused)
-upstreams = ["static://202"]   # oauth2-proxy only answers /oauth2/*; nginx serves the app
 force_https = true             # browser is HTTPS even though nginx talks to us over HTTP
 cookie_secure = true
 
 # --- recommended ----------------------------------------------------------
 session_store_type = "redis"   # keep the session cookie small (see below)
 redis_connection_url = "redis://127.0.0.1:6379"
+
+# --- optional, depending on your IdP / preference -------------------------
+skip_provider_button = true              # go straight to the IdP, skip the oauth2-proxy landing page
+# insecure_oidc_allow_unverified_email = true  # accept logins when the IdP marks email unverified
 ```
 
 That's the whole setup. The rest of this section explains how it fits together and the options worth knowing about.

From 80415c36ff0467ab666d7ffb83fa70610f74d92a Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Thu, 25 Jun 2026 14:28:11 -0400
Subject: [PATCH 17/20] docs: whitelist_domains is required in the oauth2-proxy
 config
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Our nginx @oauth2_signin redirects with an absolute rd
($scheme://$host$request_uri). oauth2-proxy's redirect validator only
accepts an absolute redirect whose host is in whitelist_domains, so it's
required for every protected app — not just the multi-subdomain case.
Add it to the required section of the example and correct the "Multiple
apps" / "Sharing one sign-in" notes, which implied it was only needed
alongside cookie_domains.
---
 .../docs/admins/core-concepts/external-domains.md         | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index 26683ed7..d1744b25 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -143,6 +143,10 @@ set_xauthrequest = true        # return identity in X-Auth-Request-* headers
 pass_access_token = true       # also expose the access token (drop if unused)
 force_https = true             # browser is HTTPS even though nginx talks to us over HTTP
 cookie_secure = true
+# Must cover every protected host — nginx sends an absolute post-sign-in
+# redirect, which oauth2-proxy rejects unless its domain is whitelisted.
+# A leading dot matches the apex and all subdomains.
+whitelist_domains = [".example.com"]
 
 # --- recommended ----------------------------------------------------------
 session_store_type = "redis"   # keep the session cookie small (see below)
@@ -165,7 +169,7 @@ A single instance can serve many services this way — they all proxy `/oauth2/*
     oauth2-proxy does **not** need to be on the NGINX host. If you want it to live behind the same load-balancer IP, expose its port with an L4 (TCP) passthrough — e.g. a **transport service**, which NGINX serves via its `stream {}` block — and point the **oauth2-proxy URL** at that address.
 
 !!! note "Multiple apps, one oauth2-proxy"
-    Leave `redirect_url` unset — oauth2-proxy derives the callback per request as `https://<requested-host>/oauth2/callback`, so each app gets the right one. Register `https://<app-host>/oauth2/callback` as a redirect URI for **each** app in your IdP. If your protected services span multiple subdomains and you want one shared sign-in, also add `cookie_domains = [".example.com"]` and `whitelist_domains = [".example.com"]`.
+    Leave `redirect_url` unset — oauth2-proxy derives the callback per request as `https://<requested-host>/oauth2/callback`, so each app gets the right one. Register `https://<app-host>/oauth2/callback` as a redirect URI for **each** app in your IdP, and make sure every protected host is covered by `whitelist_domains`. If your protected services span multiple subdomains and you want them to share a single sign-in, also add `cookie_domains = [".example.com"]`.
 
 !!! warning "Do not set `reverse_proxy`"
     Because nginx proxies straight to oauth2-proxy (nothing sits in front of it from its point of view), leave `reverse_proxy` off. Enabling it makes oauth2-proxy trust `X-Forwarded-*` headers, which this integration neither sends nor needs.
@@ -191,7 +195,7 @@ For the full header table and how applications consume the identity (server-side
 
 ### Sharing one sign-in across subdomains
 
-Because `/oauth2/*` is served on each app's own hostname, the oauth2-proxy cookie is scoped to that host by default. To share a single sign-in across multiple subdomains, add `cookie_domains = [".example.com"]` and `whitelist_domains = [".example.com"]` to the config, and make the protected services subdomains of the same parent domain.
+Because `/oauth2/*` is served on each app's own hostname, the oauth2-proxy cookie is scoped to that host by default. To share a single sign-in across multiple subdomains, add `cookie_domains = [".example.com"]` to the config (the example's `whitelist_domains = [".example.com"]` already covers the subdomains), and make the protected services subdomains of the same parent domain.
 
 ### Flow
 

From db984fa4c0089ac448ec132d2f81ac9d782e469b Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Thu, 25 Jun 2026 14:33:08 -0400
Subject: [PATCH 18/20] docs: recommend __Host- cookie name and
 cookie_samesite=lax
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add cookie_name = "__Host-oauth2_proxy" (locks the session cookie to the
exact host — each subdomain signs in separately, which is the desired
default) and cookie_samesite = "lax" (works with the OAuth redirect back
from the IdP). Update the "Sharing one sign-in" guidance to note that
sharing requires switching to the __Secure- prefix plus cookie_domains,
since __Host- forbids a Domain attribute.
---
 .../docs/admins/core-concepts/external-domains.md   | 13 +++++++++++--
 1 file changed, 11 insertions(+), 2 deletions(-)

diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index d1744b25..5fc7004e 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -143,6 +143,10 @@ set_xauthrequest = true        # return identity in X-Auth-Request-* headers
 pass_access_token = true       # also expose the access token (drop if unused)
 force_https = true             # browser is HTTPS even though nginx talks to us over HTTP
 cookie_secure = true
+cookie_samesite = "lax"        # works with the OAuth redirect back from the IdP; avoid "strict"
+# The __Host- prefix locks the cookie to the exact host that set it (no Domain
+# attribute), so each subdomain requires its own sign-in. Requires cookie_secure.
+cookie_name = "__Host-oauth2_proxy"
 # Must cover every protected host — nginx sends an absolute post-sign-in
 # redirect, which oauth2-proxy rejects unless its domain is whitelisted.
 # A leading dot matches the apex and all subdomains.
@@ -169,7 +173,7 @@ A single instance can serve many services this way — they all proxy `/oauth2/*
     oauth2-proxy does **not** need to be on the NGINX host. If you want it to live behind the same load-balancer IP, expose its port with an L4 (TCP) passthrough — e.g. a **transport service**, which NGINX serves via its `stream {}` block — and point the **oauth2-proxy URL** at that address.
 
 !!! note "Multiple apps, one oauth2-proxy"
-    Leave `redirect_url` unset — oauth2-proxy derives the callback per request as `https://<requested-host>/oauth2/callback`, so each app gets the right one. Register `https://<app-host>/oauth2/callback` as a redirect URI for **each** app in your IdP, and make sure every protected host is covered by `whitelist_domains`. If your protected services span multiple subdomains and you want them to share a single sign-in, also add `cookie_domains = [".example.com"]`.
+    Leave `redirect_url` unset — oauth2-proxy derives the callback per request as `https://<requested-host>/oauth2/callback`, so each app gets the right one. Register `https://<app-host>/oauth2/callback` as a redirect URI for **each** app in your IdP, and make sure every protected host is covered by `whitelist_domains`. With the `__Host-` cookie above, a user signs in separately per host; to share one sign-in across subdomains instead, see [Sharing one sign-in](#sharing-one-sign-in-across-subdomains).
 
 !!! warning "Do not set `reverse_proxy`"
     Because nginx proxies straight to oauth2-proxy (nothing sits in front of it from its point of view), leave `reverse_proxy` off. Enabling it makes oauth2-proxy trust `X-Forwarded-*` headers, which this integration neither sends nor needs.
@@ -195,7 +199,12 @@ For the full header table and how applications consume the identity (server-side
 
 ### Sharing one sign-in across subdomains
 
-Because `/oauth2/*` is served on each app's own hostname, the oauth2-proxy cookie is scoped to that host by default. To share a single sign-in across multiple subdomains, add `cookie_domains = [".example.com"]` to the config (the example's `whitelist_domains = [".example.com"]` already covers the subdomains), and make the protected services subdomains of the same parent domain.
+The example config scopes the session cookie to each host (via the `__Host-` cookie name), so a user signs in separately on every subdomain. To share a single sign-in across subdomains instead:
+
+- add `cookie_domains = [".example.com"]` (the example's `whitelist_domains` already covers the subdomains), and
+- change `cookie_name` to the `__Secure-` prefix (e.g. `"__Secure-oauth2_proxy"`) — the `__Host-` prefix forbids a `Domain` attribute and is therefore incompatible with `cookie_domains`.
+
+Make the protected services subdomains of the same parent domain.
 
 ### Flow
 

From c8c7c3e2742d83e5d0c3f75d390e04a4e5c26927 Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Thu, 25 Jun 2026 14:34:33 -0400
Subject: [PATCH 19/20] docs: remove shared sign-in explainations

---
 .../docs/admins/core-concepts/external-domains.md     | 11 +----------
 1 file changed, 1 insertion(+), 10 deletions(-)

diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index 5fc7004e..e9fae637 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -173,7 +173,7 @@ A single instance can serve many services this way — they all proxy `/oauth2/*
     oauth2-proxy does **not** need to be on the NGINX host. If you want it to live behind the same load-balancer IP, expose its port with an L4 (TCP) passthrough — e.g. a **transport service**, which NGINX serves via its `stream {}` block — and point the **oauth2-proxy URL** at that address.
 
 !!! note "Multiple apps, one oauth2-proxy"
-    Leave `redirect_url` unset — oauth2-proxy derives the callback per request as `https://<requested-host>/oauth2/callback`, so each app gets the right one. Register `https://<app-host>/oauth2/callback` as a redirect URI for **each** app in your IdP, and make sure every protected host is covered by `whitelist_domains`. With the `__Host-` cookie above, a user signs in separately per host; to share one sign-in across subdomains instead, see [Sharing one sign-in](#sharing-one-sign-in-across-subdomains).
+    Leave `redirect_url` unset — oauth2-proxy derives the callback per request as `https://<requested-host>/oauth2/callback`, so each app gets the right one. Register `https://<app-host>/oauth2/callback` as a redirect URI for **each** app in your IdP, and make sure every protected host is covered by `whitelist_domains`.
 
 !!! warning "Do not set `reverse_proxy`"
     Because nginx proxies straight to oauth2-proxy (nothing sits in front of it from its point of view), leave `reverse_proxy` off. Enabling it makes oauth2-proxy trust `X-Forwarded-*` headers, which this integration neither sends nor needs.
@@ -197,15 +197,6 @@ When oauth2-proxy runs with `--set-xauthrequest`, NGINX captures its `X-Auth-Req
 
 For the full header table and how applications consume the identity (server-side headers, verifying the access-token JWT, and the browser `/oauth2/userinfo` endpoint for static frontends), see [Adding Authentication](../../users/consuming-auth.md).
 
-### Sharing one sign-in across subdomains
-
-The example config scopes the session cookie to each host (via the `__Host-` cookie name), so a user signs in separately on every subdomain. To share a single sign-in across subdomains instead:
-
-- add `cookie_domains = [".example.com"]` (the example's `whitelist_domains` already covers the subdomains), and
-- change `cookie_name` to the `__Secure-` prefix (e.g. `"__Secure-oauth2_proxy"`) — the `__Host-` prefix forbids a `Domain` attribute and is therefore incompatible with `cookie_domains`.
-
-Make the protected services subdomains of the same parent domain.
-
 ### Flow
 
 ```mermaid

From 8163a8691972ed520bae99151617d6ba9724052d Mon Sep 17 00:00:00 2001
From: Robert Gingras <rgingras@mieweb.com>
Date: Thu, 25 Jun 2026 14:37:03 -0400
Subject: [PATCH 20/20] docs: remove unnessecary and confusing reverse-proxy
 warning

---
 .../docs/admins/core-concepts/external-domains.md              | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index e9fae637..967723fb 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -175,9 +175,6 @@ A single instance can serve many services this way — they all proxy `/oauth2/*
 !!! note "Multiple apps, one oauth2-proxy"
     Leave `redirect_url` unset — oauth2-proxy derives the callback per request as `https://<requested-host>/oauth2/callback`, so each app gets the right one. Register `https://<app-host>/oauth2/callback` as a redirect URI for **each** app in your IdP, and make sure every protected host is covered by `whitelist_domains`.
 
-!!! warning "Do not set `reverse_proxy`"
-    Because nginx proxies straight to oauth2-proxy (nothing sits in front of it from its point of view), leave `reverse_proxy` off. Enabling it makes oauth2-proxy trust `X-Forwarded-*` headers, which this integration neither sends nor needs.
-
 !!! warning "HTTPS scheme"
     oauth2-proxy builds its `redirect_uri` and secure cookies from the scheme of the connection it receives, which is whatever scheme you put in the **oauth2-proxy URL**:
 
