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.

2. Session Shape

A logged-in portal session carries the following attributes (names are kept in a SessionConstants class on each portal):

Attribute Purpose

BACKEND_JWT

The admin-service-signed JWT, opaque to the portal except for the exp claim cached separately

BACKEND_JWT_EXPIRY

Instant of the JWT’s absolute expiry, pre-parsed for cheap check on every relay

OAuth2AuthorizedClient (Spring-managed)

IdP access/refresh token, managed by Spring Security’s OAuth2AuthorizedClientManager; used to re-exchange when the backend JWT expires

CURRENT_TENANT_ID (admin-portal only)

The tenant currently selected by the user; server-authoritative for proxied calls

Cached tenant list (admin-portal only)

Set of {tenantId, name, role} available to this user; populated from admin-service at login and on explicit refresh

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.

jwt-relay-flow

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:

  1. Pull the OAuth2AuthenticationToken from the security context.

  2. Build an OAuth2AuthorizeRequest for the user’s client registration.

  3. Ask OAuth2AuthorizedClientManager.authorize(…​) — Spring Security uses the stored refresh token to get a fresh IdP access token automatically.

  4. Extract the new IdP access token + id_token.

  5. Call BackendTokenExchangeClient.exchangeForBackendJwt(…​) — this is the portal’s own wrapper around POST /auth/token-exchange/oauth2 (see OIDC Token Exchange).

  6. 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 /oauth2/authorization/<clientRegistrationId> for full re-login

IdP refresh succeeds but token-exchange throws

Keep the expired JWT in session; relay it anyway so admin-service can respond 401 with its own X-Token-Expired header. Don’t silently drop — the explicit 401 is diagnostic.

Session has JWT but no BACKEND_JWT_EXPIRY

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:

  1. Portal-local logout — invalidates JSESSIONID, clears session attributes, drops the backend JWT. Mandatory. Triggered by the portal’s own /logout or /api/account/logout endpoint.

  2. 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.

  3. admin-service JWT revocation — not implemented. admin-service JWTs are valid until their exp claim 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:

  1. POST /logout → Spring Security LogoutFilter

  2. Invalidate session (drops backend JWT, OAuth2AuthorizedClient, tenant state)

  3. Redirect to IdP end-session URL with id_token_hint and post_logout_redirect_uri = <portal>/logout-complete

  4. IdP redirects back to /logout-complete which 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:

  1. SameSite=Lax on the session cookie + CSRF token for state-changing SPA ⇄ gateway endpoints. Default. Lax blocks most cross-site POSTs; Spring Security’s CookieCsrfTokenRepository provides the token for POST /api/session/tenant and similar.

  2. SameSite=Strict if the portal is never navigated to via cross-site links (admin-portal is a reasonable candidate — staff navigate directly).

  3. 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 its SecurityConfig) and is reasonable — the attacker would need to both forge a cross-site request and have the session cookie arrive, which SameSite blocks.

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 BACKEND_JWT whose exp has passed

Relay the expired JWT. admin-service answers 401 + X-Token-Expired: true.

Session absent

Nothing — JSESSIONID timed out, was never created, or the pod that held it was replaced

The gateway answers 401 + X-Session-Expired: true without calling admin-service.

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

GET /api/events/{id} (public)

200 — succeeds, masking the failure

GET /api/forms/event

403AccessDeniedException

GET /api/forms/event/linked-status

403AccessDeniedException

GET /api/people/linked-to-principal

400 — principal cannot be resolved

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/account indefinitely 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

401

AuthExpiredInterceptor

Return without navigating. The recovery has already begun and will redirect.

403

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.

400, 5xx, 0/timeout

The component

Treat as a domain error. Retry is reasonable for the transient classes (0, timeout, 5xx).

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; include clientRegistrationId, 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

registration-portal/src/main/java/…​/web/filter/AdminServiceJwtRelayFilter.java

The relay filter itself

registration-portal/src/main/java/…​/security/SessionConstants.java

Session attribute keys

registration-portal/src/main/java/…​/service/BackendTokenExchangeClient.java

Portal-side wrapper around POST /auth/token-exchange/oauth2

registration-portal/src/main/java/…​/service/AdminServiceAuthClient.java

Portal-side wrapper around POST /auth/external-login (hash flow only — omit in admin-portal)

registration-portal/src/main/java/…​/config/SecurityConfig.java

Filter chain order, OIDC login, logout, CSRF policy

13. Change History

Date Change

2026-04-24

Initial draft. Grounded in registration-portal AdminServiceJwtRelayFilter + AdminServiceAuthClient.

2026-07-31

Added "Session Absence vs JWT Expiry" and the X-Session-Expired contract after a stage incident where a timed-out JSESSIONID produced 400/403 responses that the portal’s 401-keyed interceptor ignored. Corrected "Session Store & Replication" — HTTP sessions are pod-local, not Hazelcast-replicated.

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 403; once the gateway fails closed that guidance strands the user on a blank screen when a genuine 403 arrives. 401 is the interceptor’s and nothing else is.