# Design: SSO Forward Auth for Deployed Applications (Enterprise) **Status:** Approved for implementation (v1) — branch `feat/forward-auth-sso` **Author:** Engineering **Date:** 2026-05 **Audience:** Internal + enterprise customer requesting the feature ## Decisions locked for v1 - **Auth gate:** Option A — integrate `oauth2-proxy` (do not build our own auth server). - **OIDC source:** reuse the existing `sso_provider` table (read-only from this feature). - **Auth domain per server, modeled as a `domains` row:** because each server is an isolated swarm with its own proxy (§6.1), each server has its **own** auth domain (e.g. `auth-prod.acme.com`) hosting that server's single oauth2 callback, registered **once** in the IdP per server. The auth domain is a row in the existing `domains` table with `domainType: "forwardAuth"` and a `serverId` (null = local host) — so it **inherits certificates, TLS and the domain pipeline** like any app domain, instead of a separate settings table. There is no `forward_auth_settings` table. - **`domains.forwardAuthProviderId`** (FK → `sso_provider.providerId`, `ON DELETE set null`) marks an **app** domain as protected by a provider; deleting the provider auto-unprotects the domain. This is distinct from the `forwardAuth` domain row, which is the gate itself. - **Why per-server (not one global auth domain):** a single `auth.acme.com` would resolve (DNS) to one server only, and the forwardAuth check runs over each server's *internal* network — a remote server can't reach another server's proxy without exposing it publicly. Per-server keeps every server autonomous (local forwardAuth, no cross-server traffic). The cost is one IdP callback per server, which is acceptable. - **Shared base domain assumption:** the auth domain and the protected apps on a server share a base domain, so the session cookie (scoped to `baseDomain`) works across that server's apps. Apps outside that base are out of scope for v1. - **Client secret at rest:** **deferred** — the `clientSecret` stays unencrypted in `oidcConfig` for v1 (same as today). Tracked as security debt in §10. - **oauth2-proxy quirks handled:** `--insecure-oidc-allow-unverified-email` (many IdPs send `email_verified=false` → otherwise a 500), and `whitelist-domains = baseDomain` (oauth2-proxy has no universal wildcard; the base domain covers every app under it). --- ## 1. Problem statement An enterprise customer wants to place an **SSO authentication gate in front of each deployed application** (the apps/compose services that Dokploy publishes through Traefik), so that an unauthenticated visitor must log in against the company's IdP (OIDC) before reaching the app. This should be an **enterprise-only** feature, and ideally should reuse the OIDC information we already store. In short: *"Can we sit an SSO layer between Traefik and each application, reusing the OIDC tables?"* **Answer: yes, it's feasible.** Traefik supports this natively via the `forwardAuth` middleware. The hard part is not Traefik — it's the **auth proxy service** that performs the OIDC flow. This doc compares the two viable ways to build that service, and confirms what we can and cannot reuse from the existing SSO tables. --- ## 2. Critical clarification: what our OIDC data actually is The existing `sso_provider` table ([`packages/server/src/db/schema/sso.ts:7`](../../packages/server/src/db/schema/sso.ts#L7)) is owned by the **better-auth SSO plugin**. It exists so that **users can log into the Dokploy dashboard** against an external IdP (Dokploy acts as an OIDC/SAML *client*). It stores, as JSON text columns: - `oidcConfig` — `clientId`, `clientSecret`, `authorizationEndpoint`, `tokenEndpoint`, `userInfoEndpoint`, `jwksEndpoint`, `discoveryEndpoint`, `scopes`, `pkce`, and a `mapping` for user fields. - `samlConfig` — full SAML SP/IdP metadata. - `issuer`, `providerId` (unique), `domain`, `organizationId`, `userId`. > ⚠️ Important security note: the `clientSecret` lives **inside the `oidcConfig` text column as > plain JSON and is not encrypted at rest** in the schema. Reusing this data for a second > purpose (see §4) means that secret gets read and re-injected into another service's config. > That widens its blast radius and must be called out to the customer. If we reuse it we should > seriously consider encrypting it at rest as part of this work. **Key point for the customer:** this data describes Dokploy-as-an-OIDC-client. To protect their *applications*, we need a separate component (an auth proxy) that runs the OIDC **authorization-code flow on behalf of the protected app** — handle login redirect, callback, token validation, session cookie, and logout. better-auth's SSO plugin does **not** do this for third-party apps behind Traefik; it only logs users into Dokploy itself. So "reuse the OIDC tables" is possible at the level of **credentials/endpoints** (clientId, secret, issuer, scopes), but the *runtime behavior* (the actual SSO gate) is net-new regardless of approach. --- ## 3. How the Traefik side works (the easy half) Traefik's [`forwardAuth`](https://doc.traefik.io/traefik/middlewares/http/forwardauth/) middleware delegates the auth decision to an external HTTP service. For every request Traefik calls `address`; a `2xx` lets the request through (optionally copying `authResponseHeaders` back to the app), anything else (typically a `302` to the IdP) is returned to the browser. Dokploy already has everything needed to wire this up: | Capability | Where it lives today | Reuse | | --- | --- | --- | | `ForwardAuthMiddleware` Traefik type | [`utils/traefik/file-types.ts:659`](../../packages/server/src/utils/traefik/file-types.ts#L659) (`address`, `tls`, `trustForwardHeader`, `authResponseHeaders`, `authRequestHeaders`) | ✅ as-is | | Per-domain middleware chain | `domains.middlewares: text[]` column ([`db/schema/domain.ts`](../../packages/server/src/db/schema/domain.ts)) — already exists and is applied | ✅ as-is | | Attaching middleware to a router | `createDomainLabels()` joins `domain.middlewares` into `traefik.http.routers..middlewares` ([`utils/docker/domain.ts:255`](../../packages/server/src/utils/docker/domain.ts#L255)) | ✅ as-is | | Writing dynamic middleware YAML | `createSecurityMiddleware()` / `writeMiddleware()` pattern, local + remote(SSH) ([`utils/traefik/security.ts`](../../packages/server/src/utils/traefik/security.ts), [`middleware.ts`](../../packages/server/src/utils/traefik/middleware.ts)) | ✅ as pattern | | Deploying a helper container/service on the swarm | `dokploy-redis` / `dokploy-monitoring` / `dokploy-traefik` setup ([`setup/redis-setup.ts`](../../packages/server/src/setup/redis-setup.ts), [`monitoring-setup.ts`](../../packages/server/src/setup/monitoring-setup.ts), [`traefik-setup.ts`](../../packages/server/src/setup/traefik-setup.ts)) on `dokploy-network` | ✅ as pattern | | Enterprise gating | `enterpriseProcedure` + `hasValidLicense()` ([`server/api/trpc.ts:216`](../../apps/dokploy/server/api/trpc.ts#L216), [`services/proprietary/license-key.ts`](../../packages/server/src/services/proprietary/license-key.ts)) | ✅ as-is | So the Dokploy-side glue (UI toggle on a domain → write a `forwardAuth` middleware → append its name to `domains.middlewares` → reload Traefik) is **small and low-risk**. The variable is the auth service that `address` points to. --- ## 4. The decision: where does the auth flow run? This is the real fork. Both options use the *same* Traefik `forwardAuth` wiring from §3; they differ in what sits behind `address`. ### Option A — Integrate an existing forward-auth proxy (oauth2-proxy) Deploy a battle-tested proxy (e.g. [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy) or `traefik-forward-auth`) as a Dokploy-managed Docker service, configured from the OIDC credentials. Dokploy generates the proxy config + the Traefik middleware; the proxy owns the OIDC flow, sessions, and cookies. **Pros** - The security-critical part (OIDC flow, session/cookie handling, token refresh, logout, CSRF/ state) is mature, audited, and maintained externally. - We write **config + deployment glue**, not an auth server. Far less code. - oauth2-proxy supports OIDC discovery, header injection, allowed-domains/groups, and Traefik forwardAuth mode out of the box. - Deployment follows an existing pattern (`dokploy-monitoring`/`dokploy-redis` style services on `dokploy-network`, local + remote via `serverId`). **Cons** - A new bundled image to ship, version, and update across self-hosted + remote servers. - Per-org (or per-app) proxy instance + a session store (cookie-based or Redis) to manage. - Less branding control (login is the IdP's; the proxy is mostly invisible, which is usually fine). - Mapping our `oidcConfig` JSON → oauth2-proxy env/flags is a translation layer we must own and keep correct as configs vary (PKCE, custom endpoints, skipDiscovery, etc.). ### Option B — Build our own forward-auth service Write a small Dokploy auth service that implements the OIDC authorization-code flow itself and answers Traefik's forwardAuth calls. **Pros** - Full control over UX/branding, session model, and how it integrates with Dokploy orgs/permissions. - One codebase we fully understand; no third-party image to track. - Could share types/utilities with the rest of `packages/server`. **Cons** - We are now building and **owning an authentication service** — sessions, signed/encrypted cookies, CSRF/state/nonce, token validation against JWKS, refresh, logout, clock-skew, replay protection. This is a large, security-sensitive surface that is easy to get subtly wrong. - The earlier "~200 LOC service" estimate is unrealistic; a correct implementation is substantially more, plus ongoing security maintenance. - We carry the liability for any auth bug in front of customer apps. ### Recommendation **Option A (integrate oauth2-proxy).** The Traefik wiring is identical either way, so the only thing we're really choosing is whether to *own an auth server*. For a feature that gates access to customer production apps, delegating the auth flow to a mature project is the lower-risk, lower-cost, faster path. Build our own only if a hard requirement (deep branding, an unusual session model, air-gapped constraints) makes oauth2-proxy unworkable — none is evident yet. --- ## 5. Reusing `sso_provider` (per your decision) You chose to **reuse the existing `sso_provider` OIDC config** rather than add an independent table. That's workable and minimizes setup for the customer, with these caveats to design around: 1. **Semantic coupling.** `sso_provider` currently means "how Dokploy users log into the dashboard." Reusing it for "how app visitors authenticate" overloads it. The IdP/client may legitimately need to differ (different OIDC client, different allowed audience, different redirect URIs — the app's callback, not Dokploy's). Mitigation: treat `sso_provider` as the *source of issuer + base credentials*, and add a thin per-domain config (which provider, plus app-specific redirect/allowed-groups) rather than assuming a 1:1 reuse. 2. **Redirect URIs.** Each protected app needs its callback registered at the IdP (e.g. `https://app.customer.com/oauth2/callback`). The dashboard login uses Dokploy's own callback. The customer must add the app callbacks to the same OIDC client, or use a dedicated client. Document this clearly. 3. **Secret handling.** As noted in §2, reading `clientSecret` out of `oidcConfig` and injecting it into oauth2-proxy means that secret now lives in a second place (proxy config/env on the target server). Recommend encrypting `oidcConfig` at rest and passing the secret to the proxy via a Docker secret / file mount rather than a plain env var. 4. **better-auth ownership.** `register` currently round-trips through `auth.registerSSOProvider()` ([`sso.ts:251`](../../apps/dokploy/server/api/routers/proprietary/sso.ts#L251)); rows may be written by an external auth service. We should **read** from `sso_provider` for forward-auth, but avoid mutating it through the forward-auth feature to prevent fighting better-auth over the same rows. --- ## 6. Proposed architecture (Option A) ``` ┌───────────────────────────┐ Browser ──HTTPS──▶ Traefik ──forwardAuth──▶ oauth2-proxy (dokploy-managed) │ router for app.customer.com │ │ middlewares=[sso-] │ OIDC auth-code flow │ ▼ │ Customer IdP (OIDC) │ │ ◀───── 2xx + X-Auth-* headers ─────────┘ │ ▼ Deployed application ``` **New/changed pieces (all enterprise-gated):** 1. **Helper service deployment** — a `dokploy-forward-auth` (oauth2-proxy) Docker service per org (or per server), modeled on `monitoring-setup.ts` / `redis-setup.ts`, attached to `dokploy-network`, supporting local + remote (`serverId`). Config derived from the chosen `sso_provider.oidcConfig`. 2. **Traefik middleware generation** — a `createForwardAuthMiddleware()` following the `security.ts` pattern: write a `forwardAuth` entry (using `ForwardAuthMiddleware` from `file-types.ts`) to the dynamic middlewares file, `address` pointing at the helper service, with `authResponseHeaders` for the user identity headers. 3. **Domain wiring** — UI toggle "Protect with SSO" on a domain + a field to pick the provider; appends the middleware name to the existing `domains.middlewares[]` and reloads Traefik. No schema change strictly required for the chain itself; a small column or join is needed to record *which* provider protects a domain. 4. **tRPC router** — `forward-auth` router under `routers/proprietary/`, all `enterpriseProcedure`, with enable/disable-on-domain mutations. --- ### 6.1. Remote servers: one proxy per server This is forced by Dokploy's networking model, not a design preference: - **Each remote server is its own isolated Docker Swarm** (`docker swarm init` per server, [`server-setup.ts:381`](../../packages/server/src/setup/server-setup.ts#L381)). - **`dokploy-network` is an overlay local to each server's swarm** ([`server-setup.ts:438`](../../packages/server/src/setup/server-setup.ts#L438)) — it does **not** span servers. A container on the Dokploy host cannot reach a container on a remote server over `dokploy-network`. - **Each server runs its own Traefik** ([`traefik-setup.ts:120`](../../packages/server/src/setup/traefik-setup.ts#L120)); it only routes to services on that same server. Therefore Traefik on server A can only `forwardAuth` to a proxy that lives **on server A**. The deployment model is **one `dokploy-forward-auth` instance per server** (host + each remote), exactly mirroring how `dokploy-monitoring` is already deployed per server via `getRemoteDocker(serverId)` ([`monitoring-setup.ts:10`](../../packages/server/src/setup/monitoring-setup.ts#L10)). One instance per server still protects *all* apps on that server (multi-upstream), so it is not one-per-app. ``` Dokploy host: dokploy-forward-auth → protects local apps Remote server A: dokploy-forward-auth → protects A's apps Remote server B: dokploy-forward-auth → protects B's apps ``` **Session scope (v1 = isolated per server):** because oauth2-proxy sessions are cookie-based per instance, a user moving between an app on server A and an app on server B may re-authenticate. v1 accepts this. To enable shared SSO later, point all instances at a common cookie domain and the same `cookie-secret`; v1 stores these in a structured config so flipping to shared mode is config-only, not a refactor. **Lifecycle:** deploy/update the proxy per server during the `serverSetup` flow ([`server-setup.ts:47`](../../packages/server/src/setup/server-setup.ts#L47)) and/or lazily the first time a domain on that server is protected. ### 6.2. Auth domain per server (the low-friction model) The first iteration used a per-app callback (`https://app/oauth2/callback`), which meant: register a callback in the IdP **per app**, and update the proxy whitelist (a `service.update`) on every new protected domain. Too manual. v1 uses **one auth domain per server** (each server is autonomous — §6.1): ``` Per server (e.g. "Production"): 1. Admin sets "auth-prod.acme.com" for that server in SSO settings (once). → a Traefik router auth-prod.acme.com/oauth2/* → that server's oauth2-proxy → ONE callback to register in the IdP: https://auth-prod.acme.com/oauth2/callback 2. app1.acme.com on Production (SSO enabled): - no session → forwardAuth 401 → errors middleware 302s the browser to https://auth-prod.acme.com/oauth2/sign_in?rd= - login at IdP → returns to auth-prod.acme.com/oauth2/callback (the one registered) - cookie scoped to .acme.com → redirect back to app1.acme.com ✅ 3. app2.acme.com, app3.acme.com on the same server: - same flow, same callback, same cookie. ZERO new IdP config, ZERO proxy redeploy. ✅ ``` Why it removes both pain points (within a server): - **One IdP callback per server:** the redirect_uri is always that server's `auth-.acme.com/oauth2/callback`, configured once per server. - **No per-app redeploy:** cookie + whitelist are scoped to `baseDomain`, which already covers any new subdomain on that server. Wiring summary: - `forward_auth_settings`, unique per `(organizationId, serverId)`: `authDomain`, `baseDomain` (derived, e.g. `.acme.com`), `https`. `serverId = null` = local host. - Proxy env (per server): `redirect-url = ://authDomain/oauth2/callback`, `cookie-domains = baseDomain`, `whitelist-domains = baseDomain`, per-server `cookie-secret`. - Traefik: a dedicated `forward-auth-domain.yml` router for `authDomain/oauth2/*` → proxy on that server; each protected app gets a `forwardAuth` + an `errors` middleware that 302s to its server's auth domain login. The middleware resolves which auth domain to use from the app's `serverId`. Limitations (out of scope for v1): - Apps **not** under their server's `baseDomain` won't get shared SSO (cross-domain cookies). - SSO is **not** shared across servers (a user moving between apps on different servers logs in again). True cross-server SSO would require exposing one proxy publicly for cross-server forwardAuth — deliberately avoided for autonomy/latency. ## 7. Open questions for the customer / product - **Granularity:** protect per *domain*, per *application*, or per *project/environment*? - **Session scope:** single sign-on shared across all protected apps on a base domain, or isolated per app? (Affects cookie domain + whether one proxy instance is shared.) - **Authorization, not just authentication:** do they need group/role-based allow rules (e.g. only `group=engineering`), or is "any authenticated user from the IdP" enough? - **Remote servers:** must this work on remote (SSH-managed) servers from day one, or local/Dokploy-host only for v1? - **Logout / session lifetime** expectations. - **Dedicated OIDC client** for app protection vs reusing the dashboard-login client. --- ## 8. Effort estimate (Option A, design-validated) Assumes oauth2-proxy, reuse of `sso_provider`, local + remote support, one provider per domain. | Workstream | Rough effort | | --- | --- | | Helper service deploy (image choice, setup module, local+remote, lifecycle) | 3–5 d | | OIDC config → proxy config translation layer (incl. secret handling) | 2–3 d | | `createForwardAuthMiddleware()` + dynamic file write/reload (local+remote) | 2–3 d | | Domain wiring + provider linkage (schema touch, labels, enable/disable) | 2–3 d | | tRPC router + UI (toggle, provider select, status) | 2–3 d | | Security review, encryption-at-rest for secret, testing | 3–4 d | | **Total** | **~14–21 d** | Option B (own auth service) is **meaningfully larger** — add the full auth-server build plus ongoing security ownership; do not estimate it as a small delta over A. --- ## 9. Recommendation summary - **Feasible: yes.** Traefik `forwardAuth` + Dokploy's existing middleware/deploy patterns make the integration straightforward. - **Build the gate with oauth2-proxy (Option A)**, not a hand-rolled auth server. - **Reuse `sso_provider` for credentials/endpoints**, but add a thin per-domain link and treat app callbacks/redirects as distinct from dashboard login. Client-secret encryption at rest is **deferred** (see §10). - Gate everything behind `enterpriseProcedure` + valid license, consistent with existing SSO. - Resolve the §7 product questions (granularity, authorization rules, remote-server scope) before committing to the estimate. --- ## 10. Security debt (deferred to a follow-up) These are knowingly accepted for v1 and must be tracked, not forgotten: 1. **`clientSecret` unencrypted at rest.** `oidcConfig` (incl. `clientSecret`) remains plain JSON in the DB, as it is today. Reusing it for forward-auth propagates the secret to each server's proxy config. **Follow-up:** add encrypt/decrypt for `oidcConfig` and rotate. 2. **Secret transport to proxy.** Even in v1, pass `clientSecret` to oauth2-proxy via a Docker secret / mounted file, **not** a plain env var, to keep it out of `docker inspect` output. 3. **Trusted proxy.** Configure oauth2-proxy `--reverse-proxy=true` and restrict `--trusted-proxy-ip` to the Traefik instance so forwarded identity headers can't be spoofed by the upstream app or other containers. 4. **Cross-server shared session (deferred).** v1 is isolated per server (§6.1); shared SSO is a config flip later, not built now.