Standalone proxy outpost: cookie_domain is silently clobbered when multiple Providers share the filesystem session store (forward_domain + forward_single mix)
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):
case "filesystem":
cs, err := filesystemstore.GetPersistentStore(dir)
...
cs.Options.Domain = *p.CookieDomain // session.go:69filesystemstore.GetPersistentStore (filesystemstore.go:167-195) returns
a process-wide singleton:
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:
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
- Run a standalone (non-embedded) proxy outpost serving at least two
Providers: one
forward_domainwithcookie_domainset to its ownexternal_host, and at least one other Provider of any mode (in our case, fiveforward_singleProviders withcookie_domainleft empty, per the admin UI's own convention for that mode). - Restart the outpost so all Providers load into the same process.
- Visit the
forward_domainProvider's wildcard host, e.g.https://app-1.preview.example.com/, triggering the OAuth flow. - Inspect the outpost's
Set-Cookieresponse header on the redirect to the IdP:authentik_proxy_<hash>=...; Path=/; ...— noDomain=attribute, even though the Provider'scookie_domainis set correctly. - The callback (
https://preview.example.com/outpost.goauthentik.io/callback) receives no cookie from the browser (host-only cookie set onapp-1.preview.example.comdoesn't matchpreview.example.com) and returns400with an empty body. Outpost logs show"mismatched session ID"/"invalid state"withshould:"".
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 TraefikforwardAuth, serving six Providers (fiveforward_single, oneforward_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.
Source: goauthentik/authentik