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, replicated via Hazelcast across gateway pods. Sticky sessions at the ingress controller keep a single JSESSIONID on one pod in steady state; Hazelcast replication covers pod restart / autoscale / failover without logout. See Hazelcast Configuration for the replication setup.
For local development, a single JVM + a single browser = no replication required. Hazelcast runs in standalone mode in the dev profile.
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. 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.
10. 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 |