#26228·authentik

Standalone proxy outpost: cookie_domain is silently clobbered when multiple Providers share the filesystem session store (forward_domain + forward_single mix)

Author: s3ntin3l8Created Sep 17, 2026Updated Sep 17, 2026

Describe the bug

On a standalone (non-embedded) proxy outpost serving more than one Provider, only the last Provider loaded during outpost startup gets its cookie_domain actually applied to the session cookie. Every earlier Provider's session cookie is emitted without a Domain= attribute (host-only), regardless of what cookie_domain is set to on that Provider.

For a forward_domain mode Provider — the mode that exists specifically to cover a wildcard host (*.preview.example.com) with one session — this is fatal: the OAuth callback lands on the Provider's own external_host (preview.example.com), a different host than the one that set the session cookie (app-1.preview.example.com). A host-only cookie never reaches the callback host, so the outpost can't find the session, logs "invalid state" / "mismatched session ID" with should:"" (empty — no session found at all), and the callback returns an empty-bodied 400. The flow never completes; nothing in the response tells the client why.

Root cause

internal/outpost/proxyv2/application/session.go's getStore (verified at tag version/2026.5.5, and byte-identical at version/2026.8.2, so this is not a regression and upgrading does not fix it):

go
case "filesystem":
    cs, err := filesystemstore.GetPersistentStore(dir)
    ...
    cs.Options.Domain = *p.CookieDomain          // session.go:69

filesystemstore.GetPersistentStore (filesystemstore.go:167-195) returns a process-wide singleton:

go
var (
    globalStore *Store
    mu          sync.Mutex
)

// GetPersistentStore creates a new filesystem store if it is the first time
// the function has been called, or if the path string has changed. It then
// stores this in the globalStore variable.
// If the function is called multiple times, the store from the variable is
// returned to ensure that only one instance is running.
func GetPersistentStore(path string) (*Store, error) {
    ...
    if globalStore == nil || globalStore.storePath != path {
        ...
        store, err := NewStore(path)
        ...
        globalStore = store
        ...
    }
    return globalStore, nil
}

getStore runs once per Provider at outpost load and unconditionally overwrites globalStore.Options.Domain (and .Secure, .MaxAge, .Codecs) on the shared instance. Whichever Provider's application initializes last wins for every Provider on that outpost from then on. proxyv2.go:57-60 shows the PostgreSQL store is selected only when the outpost is embedded (ac.IsEmbedded()), so every standalone outpost hits this filesystem path unconditionally — there's no environment variable to opt into the per-application-correct PostgreSQL store.

Corroborating evidence that Provider config itself is not the problem: oauth_state.go:63 logs a specific warning when the redirect URI's hostname isn't covered by *a.proxyConfig.CookieDomain:

go
if !strings.HasSuffix(u.Hostname(), *a.proxyConfig.CookieDomain) {
    a.log.WithField("host", u.Hostname()).WithField("dom", *a.proxyConfig.CookieDomain).
        Warning("redirect URI Hostname was not included in cookie domain")
}

That warning never fires in the logs for the affected Provider — its own proxyConfig.CookieDomain is correctly populated throughout. Only the cookie actually written to the browser lacks Domain=, which is only explained by the shared store's Options.Domain having been overwritten by a later Provider's getStore call.

Also relevant: session.go's getAllCodecs() already works around this same singleton for the cookie secret (it aggregates every application's own secret into one codec list so any application's session can be decoded regardless of load order) — nothing does the equivalent for Options.Domain.

To Reproduce

  1. Run a standalone (non-embedded) proxy outpost serving at least two Providers: one forward_domain with cookie_domain set to its own external_host, and at least one other Provider of any mode (in our case, five forward_single Providers with cookie_domain left empty, per the admin UI's own convention for that mode).
  2. Restart the outpost so all Providers load into the same process.
  3. Visit the forward_domain Provider's wildcard host, e.g. https://app-1.preview.example.com/, triggering the OAuth flow.
  4. Inspect the outpost's Set-Cookie response header on the redirect to the IdP: authentik_proxy_<hash>=...; Path=/; ...no Domain= attribute, even though the Provider's cookie_domain is set correctly.
  5. The callback (https://preview.example.com/outpost.goauthentik.io/callback) receives no cookie from the browser (host-only cookie set on app-1.preview.example.com doesn't match preview.example.com) and returns 400 with an empty body. Outpost logs show "mismatched session ID" / "invalid state" with should:"".

Expected behavior

Each Provider's session cookie should carry that Provider's own cookie_domain, independent of load order or of other Providers on the same outpost — the way getAllCodecs() already handles the cookie secret for the analogous multi-Provider case.

Version and Deployment

  • authentik version: 2026.5.5 (server + outpost, in sync). Confirmed the relevant code is unchanged at version/2026.8.2.
  • Deployment: standalone (non-embedded) Docker outpost (ghcr.io/goauthentik/proxy), behind Traefik forwardAuth, serving six Providers (five forward_single, one forward_domain).

Additional context

This may be related to #22744 (admin UI silently clears cookie_domain on save for non-forward_domain Providers) in that both concern cookie_domain handling being incomplete outside the single-Provider, single-mode case — but the mechanism here is different (a runtime session- store singleton, not a UI form bug) and reproduces purely via the API/ Terraform provider, with no admin UI interaction at all.