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, 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; 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.

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

12. Change History

Date Change

2026-04-24

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