This document describes how UAA implements per-zone session isolation for
path-based identity zones (/z/{subdomain}/). A single browser JSESSIONID
cookie holds separate, isolated session state for each zone the user visits.
With subdomain-based zones (tenant.login.example.com), the browser naturally
separates cookies by domain. With path-based zones
(login.example.com/z/tenant/), all requests hit the same host and share a
single JSESSIONID cookie. Without additional work, logging in to one zone
would overwrite the security context of another, and logging out of any zone
would destroy all sessions.
The implementation follows Idea 1 from
session-persistence-zone-paths.md: a
single JSESSIONID cookie, a single underlying container/Spring Session, and
server-side namespacing by context path. Each zone's session attributes are
stored directly on the container session under prefixed keys
(containerSessionAttributeName + ATTRIBUTE_KEY_DELIMITER + attributeName), so
Spring Session sees every read/write immediately and there is no flush step or
timing dependency (e.g. avoids MySQL JDBC session visibility issues).
| Class | Role |
|---|---|
ZonePathContextRewritingFilter |
Rewrites /uaa/z/tenant/login so context path becomes /uaa/z/tenant and servlet path becomes /login. Also wraps the response to normalize cookie paths. |
SessionRepositoryFilter (Spring Session) |
Loads/saves the container session from the configured store (JDBC or in-memory). |
ZoneContextPathSessionFilter |
Wraps the request so getSession() returns a ZonePathHttpSession scoped to the current context path. Wraps the response to prevent premature JSESSIONID clearing. |
ZoneContextPathSessionRequestWrapper |
HttpServletRequestWrapper that intercepts getSession() and changeSessionId(). Caches a single ZonePathHttpSession per request (since each request has exactly one context path). If the cached sub-session has been invalidated, the cache is cleared so getSession(true) creates a fresh sub-session. |
ZoneContextPathSessionResponseWrapper |
HttpServletResponseWrapper that blocks Set-Cookie headers that would clear the JSESSIONID (since other zones may still be active). |
ZonePathHttpSession |
HttpSession view over one zone's attributes (each stored on the container as a prefixed key). Tracks per-sub-session creationTime, lastAccessedTime, and isNew independently of the container session (persisted as reserved prefixed attributes so they survive serialization). invalidate() removes only this zone's attributes and marks the sub-session as invalidated; it does not invalidate the container session. Uses TimeService for timestamps (injectable for testing). |
The order of the servlet filters is critical. Each filter wraps the request
and/or response, and the last filter to wrap is the wrapper that application
code (Spring Security, controllers) sees first via request.getSession().
┌─────────────────────────────────────────────────────────────────────┐
│ Incoming HTTP Request │
│ GET /uaa/z/tenant/login HTTP/1.1 │
│ Cookie: JSESSIONID=abc123 │
└────────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ ① ZonePathContextRewritingFilter (order: HIGHEST + 1) │
│ │
│ • Rewrites request: │
│ contextPath: /uaa → /uaa/z/tenant │
│ servletPath: /z/tenant/login → /login │
│ • Sets request attributes: │
│ ZONE_SUBDOMAIN_FROM_PATH = "tenant" │
│ ZONE_ORIGINAL_CONTEXT_PATH = "/uaa" │
│ • Wraps response with CookiePathRewritingResponse: │
│ rewrites cookie path "/" → "/uaa" (the original context path) │
│ │
└────────────────────────────────┬────────────────────────────────────┘
│ request: contextPath = /uaa/z/tenant
│ response: cookie-path-rewriting wrapper
▼
┌─────────────────────────────────────────────────────────────────────┐
│ ② SessionRepositoryFilter (Spring Session) (order: HIGHEST + 2) │
│ │
│ • Loads the session from the configured store (JDBC / memory) │
│ using the JSESSIONID cookie value. │
│ • Wraps the request so getSession() returns a Spring Session- │
│ backed HttpSession (the "container session"). │
│ • On response completion, persists dirty session attributes back │
│ to the store. │
│ • Cookie path is forced to "/" by UaaSessionConfig, then the │
│ CookiePathRewritingResponse (from step ①) rewrites it to the │
│ original context path (e.g. /uaa). │
│ │
└────────────────────────────────┬────────────────────────────────────┘
│ request: getSession() → Spring Session
▼
┌─────────────────────────────────────────────────────────────────────┐
│ ③ ZoneContextPathSessionFilter (order: HIGHEST + 51) │
│ │
│ • Wraps request with ZoneContextPathSessionRequestWrapper: │
│ getSession() now returns a ZonePathHttpSession scoped to │
│ the current contextPath ("/uaa/z/tenant"). │
│ • Wraps response with ZoneContextPathSessionResponseWrapper: │
│ blocks Set-Cookie headers that would clear JSESSIONID │
│ (protects other zones' sessions). │
│ • On completion (finally block): if no zone attributes remain on │
│ the container session, emits a Set-Cookie to clear the JSESSIONID.│
│ (No flush step — zone attributes are written directly to the │
│ container session.) │
│ │
└────────────────────────────────┬────────────────────────────────────┘
│ request: getSession() → ZonePathHttpSession
│ response: blocks JSESSIONID clearing
▼
┌─────────────────────────────────────────────────────────────────────┐
│ ④ Spring Security Filter Chain (order: -100) │
│ │
│ • Includes IdentityZoneResolvingFilter, authentication filters, │
│ SecurityContextPersistenceFilter, etc. │
│ • Calls request.getSession() and gets the ZonePathHttpSession. │
│ • Reads/writes SPRING_SECURITY_CONTEXT from/to the zone-scoped │
│ sub-session — fully isolated from other zones. │
│ │
└────────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ DispatcherServlet / Controllers │
└─────────────────────────────────────────────────────────────────────┘
| Filter | Ordered value |
Configured in |
|---|---|---|
ZonePathContextRewritingFilter |
HIGHEST_PRECEDENCE + 1 |
ZonePathContextRewritingFilterConfiguration |
SessionRepositoryFilter |
HIGHEST_PRECEDENCE + 2 |
UaaBootConfiguration.sessionRepositoryFilterRegistration() |
ZoneContextPathSessionFilter |
HIGHEST_PRECEDENCE + 51 |
ZonePathContextRewritingFilterConfiguration |
Spring Security (springSecurityFilterChain) |
-100 (Spring Boot default) |
Spring Boot auto-configuration |
For traditional WAR deployments, the same order is established by registration
sequence in UaaWebApplicationInitializer.onStartup().
The container session (the one managed by Spring Session or Tomcat) holds one attribute per zone attribute, not one map per zone. The key for a zone attribute is:
<containerSessionAttributeName><ATTRIBUTE_KEY_DELIMITER><attributeName>
where containerSessionAttributeName is
org.cloudfoundry.identity.uaa.zone.ZonePathHttpSession.<contextPathKey> (with
empty context path mapped to "default"), and ATTRIBUTE_KEY_DELIMITER is ".".
For example, SPRING_SECURITY_CONTEXT for zone /uaa/z/tenant1 is stored under
...ZonePathHttpSession./uaa/z/tenant1.SPRING_SECURITY_CONTEXT. This way every
ZonePathHttpSession.setAttribute() calls containerSession.setAttribute() directly,
so Spring Session's dirty-tracking sees the change immediately and there is no
flush step or timing dependency (which avoids MySQL JDBC session visibility issues).
In addition to application attributes, each sub-session stores two reserved metadata attributes under the same prefix:
__creationTime__— the epoch millis when this sub-session was first created.__lastAccessedTime__— the epoch millis of the most recentgetSession()access.
These are used by getCreationTime(), getLastAccessedTime(), and isNew() to
report per-sub-session values instead of delegating to the container session. On
construction, if __creationTime__ is found on the container, the sub-session is
considered a reconnection (isNew() == false); otherwise it is new. When a
sub-session is invalidated, these metadata attributes are removed along with all
other prefixed attributes, so a subsequent getSession(true) creates a fresh
sub-session with new timestamps and isNew() == true. The reserved keys are
filtered out of getAttributeNames() so they are invisible to application code.
Requests to /z/default/... are not rejected. They are rewritten so that
the context path includes /z/default (e.g. /uaa/z/default), like any other
zone path. ZoneContextPathSessionRequestWrapper maps context path
/uaa/z/default to the same session key as the root (e.g. /uaa), so
/profile and /z/default/profile share the same cookie and session.
Example — a user logged in to the default zone and two path-based zones:
Container session id: abc123
Attributes:
...ZonePathHttpSession.default.__creationTime__ → 1710000000000
...ZonePathHttpSession.default.__lastAccessedTime__ → 1710000060000
...ZonePathHttpSession.default.SPRING_SECURITY_CONTEXT → <admin auth>
...ZonePathHttpSession./uaa/z/tenant1.__creationTime__ → 1710000010000
...ZonePathHttpSession./uaa/z/tenant1.__lastAccessedTime__ → 1710000070000
...ZonePathHttpSession./uaa/z/tenant1.SPRING_SECURITY_CONTEXT → <alice auth>
...ZonePathHttpSession./uaa/z/tenant2.__creationTime__ → 1710000020000
...ZonePathHttpSession./uaa/z/tenant2.__lastAccessedTime__ → 1710000080000
...ZonePathHttpSession./uaa/z/tenant2.SPRING_SECURITY_CONTEXT → <bob auth>
- Browser sends
POST /uaa/z/tenant/login.dowithJSESSIONID=abc123. - Filters ①–③ process the request; Spring Security authenticates the user.
- Spring Security stores the
SecurityContextviasession.setAttribute(...). BecausegetSession()returns aZonePathHttpSession, the write goes directly to the container session under a prefixed key, so Spring Session marks it dirty immediately. - Filter ② commits the session to the store (JDBC or memory).
- The
JSESSIONIDcookie (path/uaa) is shared across all zone paths.
- Browser sends
GET /uaa/z/other/profilewith the sameJSESSIONID=abc123. - Filter ② loads the same container session from the store.
- Filter ③ provides a
ZonePathHttpSessionfor context path/uaa/z/other. - Spring Security reads
SPRING_SECURITY_CONTEXTfrom the/uaa/z/othersub-session — this is a completely separate authentication from/uaa/z/tenant.
- Browser sends
GET /uaa/z/tenant/logout.do. - Spring Security calls
session.invalidate()on theZonePathHttpSession. ZonePathHttpSession.invalidate()removes all container session attributes whose key starts with this zone's prefix (including the reserved__creationTime__and__lastAccessedTime__metadata). The sub-session is marked as invalidated (isInvalidated() == true). The container session itself is not invalidated.- If code subsequently calls
request.getSession(true)on the same request,ZoneContextPathSessionRequestWrapperdetects that the cached sub-session is invalidated, clears its cache, and creates a freshZonePathHttpSession. The new sub-session hasisNew() == trueand freshcreationTime/lastAccessedTimetimestamps, while other zones' sub-sessions are unaffected. - The
ZoneContextPathSessionResponseWrapperblocks Spring Security's attempt to clear theJSESSIONIDcookie (since other zones are still active). - In the filter's
finallyblock,maybeClearJSessionIdIfNoSubSessionschecks whether any container attributes with the zone prefix remain. If yes (other zones are still logged in), the cookie is left alone. If none remain, aSet-Cookieheader is emitted to clear theJSESSIONID.
If something invalidates the container session itself (not the sub-session),
all zones lose their sessions simultaneously. This mirrors the behavior of
subdomain-based zones where the session cookie would be deleted by the
browser. Application code should only invalidate the ZonePathHttpSession,
never the container session directly.
Spring Session's DefaultCookieSerializer is configured with a fixed cookie
path of "/" (UaaSessionConfig.uaaCookieSerializer()). This ensures the
browser always sends the JSESSIONID regardless of which zone path is being
accessed.
The CookiePathRewritingResponse (part of ZonePathContextRewritingFilter)
rewrites cookie paths from "/" to the original context path (e.g. /uaa) so
the cookie is properly scoped to the UAA deployment and not the entire domain.
Spring Session JDBC tracks which session attributes have been modified using a
delta map. Only attributes explicitly set via containerSession.setAttribute()
are considered dirty. ZonePathHttpSession stores each zone attribute directly
on the container session under a prefixed key (containerSessionAttributeName + ATTRIBUTE_KEY_DELIMITER + name), so every setAttribute call is a
containerSession.setAttribute(). Spring Session therefore sees each change
immediately, and no flush step is needed. This also avoids timing/visibility
issues that can occur with MySQL and Spring Session JDBC when attributes are
batched (e.g. in a map) and written only at request end.
| Test class | Scope |
|---|---|
ZoneContextPathSessionTests (server module, unit) |
ZonePathHttpSession, ZoneContextPathSessionRequestWrapper, ZoneContextPathSessionResponseWrapper, ZoneContextPathSessionFilter — attribute isolation, invalidation semantics, direct container storage, cookie blocking, multi-zone lifecycle, per-sub-session isNew/creationTime/lastAccessedTime tracking, isInvalidated flag, wrapper cache clearing after invalidate |
ZonePathSessionMockMvcTests (uaa module, MockMvc) |
End-to-end login, profile access, and logout across multiple zones using MockMvc with the real filter chain. |
ZoneSessionPathsIT (uaa module, integration) |
Selenium-driven tests against a running UAA server. Logs in to default zone and two path-based zones, verifies profile isolation, verifies logout from one zone leaves others intact. |
| Property | Value | Effect |
|---|---|---|
servlet.session-store |
database or memory |
Selects Spring Session JDBC or in-memory store. |
servlet.session-cookie.encode-base64 |
true/false |
Base64 encoding of the JSESSIONID value. |
| Cookie path | Fixed to "/" in UaaSessionConfig |
Ensures one JSESSIONID is sent for all paths; rewritten to context path by CookiePathRewritingResponse. |