Session-Held JWT & JSESSIONID
1. Overview
The admin-service JWT never reaches the browser. A portal gateway stores it in the HTTP session and injects it on every proxied call to admin-service. The browser holds only an HTTP-only JSESSIONID cookie, the portal holds the credential, and admin-service receives a standard Authorization: Bearer header.
This page describes the session shape, the relay filter that attaches the JWT, the refresh state machine, logout semantics, and the CSRF posture that falls out of this design.
See also: Portal Pattern, OIDC ⇄ admin-service Token Exchange.
2. Session Shape
A logged-in portal session carries the following attributes (names are kept in a SessionConstants class on each portal):
| Attribute | Purpose |
|---|---|
|
The admin-service-signed JWT, opaque to the portal except for the |
|
|
|
IdP access/refresh token, managed by Spring Security’s |
|
The tenant currently selected by the user; server-authoritative for proxied calls |
Cached tenant list (admin-portal only) |
Set of |
Nothing in the session is business data. The session is portal-local plumbing for auth; admin-service remains stateless.
3. The JWT Relay Filter
Every request for /services/admin-service/** flows through AdminServiceJwtRelayFilter (Spring @Component, @Order(Ordered.HIGHEST_PRECEDENCE + 1)). The filter wraps the request with an injected Authorization header and delegates.
Reference: [AdminServiceJwtRelayFilter.java](registration-portal/src/main/java/za/co/idealogic/registration/ui/web/filter/AdminServiceJwtRelayFilter.java) in registration-portal.
3.1. Why a request wrapper, not response.setHeader?
The filter must inject a request header that admin-service sees as if the browser had sent it. HttpServletRequestWrapper overrides getHeader, getHeaders, and getHeaderNames for Authorization while delegating everything else. Proxies downstream of the filter (Spring Cloud Gateway MVC) see the wrapped request naturally — no special integration required.
3.2. Why HIGHEST_PRECEDENCE + 1?
The filter must run before Spring Security in the filter chain so that the injected Authorization header participates in Spring’s own authentication logic on any portal-local endpoints that happen to share the path. +1 reserves absolute top spot for the tenant resolution filter (Order -100 in the reference implementation — tenant must be known before anything else can use it).
3.3. Expiry buffer
The relay filter refreshes 30 seconds before the JWT’s nominal expiry (TOKEN_EXPIRY_BUFFER = Duration.ofSeconds(30)). This absorbs clock skew between portal and admin-service, and gives the re-exchange path room to complete before admin-service rejects the call. Do not shrink without reason; do not grow so far that you flood admin-service with exchange calls.
4. OIDC Refresh Path
When the JWT is expiring and the user authenticated via OIDC, tryRefreshViaOidc does:
-
Pull the
OAuth2AuthenticationTokenfrom the security context. -
Build an
OAuth2AuthorizeRequestfor the user’s client registration. -
Ask
OAuth2AuthorizedClientManager.authorize(…)— Spring Security uses the stored refresh token to get a fresh IdP access token automatically. -
Extract the new IdP access token + id_token.
-
Call
BackendTokenExchangeClient.exchangeForBackendJwt(…)— this is the portal’s own wrapper aroundPOST /auth/token-exchange/oauth2(see OIDC Token Exchange). -
Store the new backend JWT + expiry in the session.
If any of these fails:
| Failure | Portal behaviour |
|---|---|
OAuth2 authorize throws (e.g. IdP refresh-token invalid) |
Redirect to |
IdP refresh succeeds but token-exchange throws |
Keep the expired JWT in session; relay it anyway so admin-service can respond |
Session has JWT but no |
Hash-based auth path — cannot refresh. Relay as-is; admin-service will reject when expired. |
The deliberate "do not clear the expired JWT" pattern exists so admin-service stays the single source of truth for JWT validity. The portal does not pre-emptively hide a stale token from admin-service.
4.1. Hash-based auth cannot refresh
Users who logged in via POST /auth/external-login (registration-portal only) have no IdP refresh token. When their JWT expires, the relay filter passes the expired token through; admin-service returns 401; portal-side code handles it by prompting the user to use the original hash link again. admin-portal does not use this path — OIDC only at launch.
5. Logout Semantics
Three states to consider:
-
Portal-local logout — invalidates
JSESSIONID, clears session attributes, drops the backend JWT. Mandatory. Triggered by the portal’s own/logoutor/api/account/logoutendpoint. -
IdP single-logout — optional. If the IdP supports OIDC Single Logout (RP-initiated), the portal redirects to
<idp>/.well-known/end_session_endpoint?id_token_hint=…after local logout. Implementation is IdP-specific; some IdPs (e.g. certain Azure B2C tenants) do not support clean end-session flows. -
admin-service JWT revocation — not implemented. admin-service JWTs are valid until their
expclaim regardless of portal logout. This is acceptable because (a) the JWT never leaves the portal session, so logout effectively removes it, and (b) token lifetime is 24 hours. If a stricter revocation model is needed in future, admin-service would need a blacklist or short-lived tokens + refresh; defer until a concrete requirement emerges.
Recommended default for admin-portal: portal-local logout + IdP single-logout. Logout endpoint sequence:
-
POST /logout→ Spring SecurityLogoutFilter -
Invalidate session (drops backend JWT, OAuth2AuthorizedClient, tenant state)
-
Redirect to IdP end-session URL with
id_token_hintandpost_logout_redirect_uri = <portal>/logout-complete -
IdP redirects back to
/logout-completewhich shows a "signed out" page
6. CSRF Posture
Because the browser holds only JSESSIONID (HTTP-only) and the portal holds the credential (the JWT), CSRF protection for /services/admin-service/** is needed but simpler than in a JWT-in-browser model.
Three postures, in order of preference:
-
SameSite=Laxon the session cookie + CSRF token for state-changing SPA ⇄ gateway endpoints. Default.Laxblocks most cross-site POSTs; Spring Security’sCookieCsrfTokenRepositoryprovides the token forPOST /api/session/tenantand similar. -
SameSite=Strictif the portal is never navigated to via cross-site links (admin-portal is a reasonable candidate — staff navigate directly). -
Exempt
/services/admin-service/from CSRF** because admin-service validates the JWT independently and the gateway-proxied path is idempotent w.r.t. session. This is registration-portal’s current posture (see itsSecurityConfig) and is reasonable — the attacker would need to both forge a cross-site request and have the session cookie arrive, whichSameSiteblocks.
Do not disable CSRF wholesale; do not store the JWT in a non-HttpOnly cookie.
7. Session Store & Replication
Sessions are held in the JVM heap of the gateway pod that created them, with a server.servlet.session.timeout of 30 minutes.
HTTP sessions are not replicated today. registration-portal wires Hazelcast for its second-level cache only — no Spring Session (spring-session-hazelcast, @EnableHazelcastHttpSession) is on the classpath, so JSESSIONID is pod-local. Every deployment, restart, eviction or scale-down silently signs out every user holding a session on that pod. registration-portal currently runs a single replica, so a redeploy invalidates all live sessions.
|
This makes the session-absent path in Session Absence vs JWT Expiry a routine occurrence rather than an edge case, and it is why that path must degrade cleanly. Two independent triggers to design for:
-
Idle timeout — 30 minutes without a request. Common in registration, where a user opens the entry form and finishes it later.
-
Pod replacement — a release, a node drain, or an OOM kill. Affects every concurrent user simultaneously and without warning.
Adding spring-session-hazelcast would remove the second trigger and is the natural follow-up; it is deliberately out of scope of the recovery contract above, because a portal must handle session absence correctly even when replication exists (the idle timeout never goes away).
For local development, a single JVM + a single browser = no replication required.
8. X-Token-Expired Header Convention
When admin-service receives an expired JWT (possible in the hash-based flow or if the OIDC refresh failed silently), it returns 401 Unauthorized with an X-Token-Expired: true response header. The portal can intercept this and redirect the user to /oauth2/authorization/… for a fresh login without showing a generic "session expired" error. registration-portal’s frontend interceptor already implements this; admin-portal inherits it via @ems/shared-ui.
9. Session Absence vs JWT Expiry
These are two different failure modes, and only one of them was originally handled. Both must terminate in the same portal-side recovery.
| Failure | What the portal holds | Contract |
|---|---|---|
JWT expired |
A session, and a |
Relay the expired JWT. admin-service answers |
Session absent |
Nothing — |
The gateway answers |
The session-absent case must not be forwarded to admin-service anonymously. Doing so produces a scatter of status codes that no client can interpret as "your session died":
Proxied call with no Authorization header |
admin-service answers |
|---|---|
|
|
|
|
|
|
|
|
A portal interceptor keyed on 401 sees none of these, so no recovery fires and the raw errors surface to the user as generic red alerts. 403 must stay reserved for its real meaning — authenticated, but not permitted — so the discrimination has to happen at the gateway, which is the only component that knows whether a session exists.
9.1. Portal-side recovery contract
401 from /services/ carrying either** X-Token-Expired or X-Session-Expired means "re-authenticate"; the portal routes to its session-expired screen (or straight to the guest re-registration for anonymous flows). Two supporting rules make that recovery reliable:
-
The cached account must be invalidated on any auth failure. A portal that memoises
GET /api/accountindefinitely will keep reporting an authenticated user after the session is gone, so route guards pass and the login screen bounces straight back to the failing page. -
Feature screens must not swallow auth errors. A component whose error handler treats any failure as "user cancelled" and navigates away will clobber the interceptor’s navigation, because interceptors run before the subscriber’s error callback.
9.1.1. Which component owns which status
401 is the interceptor’s, and only 401. Every other status — including 403 — belongs to the component that made the call.
This is narrower than it first appears, and the distinction only became safe once the gateway started failing closed. Before that, a dead session produced 403, so a component had to suppress 403 to avoid clobbering the recovery. Now a session-less call is stopped at the gateway and never reaches admin-service, so a 403 arriving at a component genuinely means authenticated, but not permitted — a resource belonging to another user, for example. The interceptor deliberately ignores it, so a component that also ignores it leaves the user on a blank screen with nothing but a global alert.
| Status | Owner | Component’s obligation |
|---|---|---|
|
|
Return without navigating. The recovery has already begun and will redirect. |
|
The component |
Treat as a domain error: exit through the return-state contract, or render an actionable message. Never retry — permissions do not change between attempts. |
|
The component |
Treat as a domain error. Retry is reasonable for the transient classes ( |
A component that suppresses 403 because "auth belongs to the interceptor" reproduces the blank-screen defect. Suppress 401 alone.
10. Observability
Log once per state transition, not once per relayed request. Relevant events:
-
INFO— new backend JWT stored in session (login, refresh) -
INFO— IdP token expired, cannot refresh, user redirected to re-auth -
DEBUG— "Adding JWT to Authorization header" (per-request; trace only in dev) -
ERROR— token exchange failed; includeclientRegistrationId, correlation ID, not the JWT itself
Never log the JWT. The relay filter logs it at TRACE only; keep the root logger above that in production.
11. Reference Implementation
| File | Role |
|---|---|
|
The relay filter itself |
|
Session attribute keys |
|
Portal-side wrapper around |
|
Portal-side wrapper around |
|
Filter chain order, OIDC login, logout, CSRF policy |
13. Change History
| Date | Change |
|---|---|
2026-04-24 |
Initial draft. Grounded in registration-portal |
2026-07-31 |
Added "Session Absence vs JWT Expiry" and the |
2026-08-01 |
Added "Which component owns which status". The previous wording ("auth statuses belong to the interceptor") was written against the pre-fix world where a dead session produced |