Writing · 2026-09-02

SSO across split portals — SAML 2.0, OpenID Connect, and logout that must not take the estate with it

How a worker landing page federates independently deployed portals with SAML 2.0 or OpenID Connect — and why logging out of one application was returning 500s and a spinner on the others.

  • SSO
  • SAML 2.0
  • OpenID Connect
  • Spring Security
Worker landing page is the hub. The IdP holds the federated session (SAML 2.0 or OpenID Connect). Each portal is its own relying party and calls only its own API. Logout must clear every local session and the IdP session — not one app’s cookie.

A benefits platform had been split along domain lines: Customer Portal, Worker Portal, a quality-control application, and other extracted domains, each with its own API. Each application owns its UI and its API. That is the right Strangler outcome. It is also an identity problem the monolith never had.

Case workers then needed a landing page: one place to sign in and open any of those portals. Single sign-on is the product requirement. Logout is the part that fails in production if it is treated as a local cookie clear.

The figure above is the operating model. The identity provider holds the worker session. Each portal is a relying party (OpenID Connect) or a service provider (SAML 2.0). Each API is a resource server. The landing page is a hub, not a shared monolith session.

Two ways to implement the same SSO

SAML 2.0 and OpenID Connect both answer “who is this worker, and has the IdP already authenticated them?” They do not share a wire format, and they do not fail in the same place.

SAML 2.0 is still the language of many enterprise and partner identity providers. The browser carries an AuthnRequest to the IdP and returns a signed assertion to the service provider. After the assertion is accepted, the portal creates a local session. Attributes (role, office, program) arrive in the assertion; they are not a substitute for an API access token unless the platform mints one from them. Single Logout (SLO) is a separate profile: the IdP is supposed to notify every service provider that registered for logout. Many IdPs implement login completely and SLO partially.

OpenID Connect sits on OAuth 2.0. The portal is a relying party. The authorization-code flow (with PKCE on a public SPA) yields an ID token (identity) and an access token (API). Refresh tokens, if issued, are a second session that outlives the browser tab. RP-initiated logout hits the IdP end_session_endpoint. Optional back-channel logout lets the IdP notify other relying parties without depending on the browser still being on that tab. A typical worker stack uses OIDC at the edge, Spring Security in the service, and JWT with RBAC on the APIs, while some partner and legacy paths still speak SAML.

Choose the protocol the IdP actually offers. Do not run both on the same portal “for flexibility.” A landing page may broker: SAML to one enterprise IdP, OIDC to another, then a platform session the portals understand. Brokering is an identity service. It is not a localStorage flag copied into five front-end apps.

Architecture after the split

Each portal remained independently deployable:

  • Customer Portal and its customer-facing APIs
  • Worker Portal and case-working APIs
  • Quality-control application and its APIs
  • Other extracted domains, each with their own contracts

SSO does not mean those APIs share a servlet session. The browser authenticates at the IdP. The landing page and each portal establish a local session or store tokens for their origin. Resource servers validate a JWT (or an introspection result), not “the user clicked a link from the hub.”

A working request path looks like this:

  1. Worker opens the landing page and authenticates at the IdP (SAML POST/Redirect or OIDC authorization code).
  2. The landing page receives identity and group claims, then renders tiles for the portals the worker is allowed to enter.
  3. Navigation to Worker Portal or quality-control is a new relying party. If the IdP session is still valid, the second login is silent (prompt=none / existing SAML session). The worker does not type a password again.
  4. That portal’s SPA calls its API with a token or a cookie scoped to that application. Quality-control must not send Worker Portal cookies to the quality-control API and hope Spring Security sorts it out.

If step 4 is implemented as one shared cookie on a parent domain, you have re-coupled the strangler. Login will look like SSO. Logout will look like an outage.

Challenges that show up after go-live

  • Protocol mismatch. A SAML-only IdP will not complete an OIDC code flow. An OIDC-only workforce will not POST a SAML assertion. The landing page must pick one federation per actor, or a broker that speaks both.
  • Clock skew and metadata. SAML assertions are short-lived and signed. Expired IdP certificates and unsynced clocks look like “SSO is down.”
  • Claim mapping. Worker role in the IdP is not the same as RBAC in quality-control versus Worker Portal. Each API still authorizes on its resources.
  • Silent SSO vs. new tab. The second portal must be registered at the IdP. An unregistered redirect URI is a failed login, not a 500 from the case API.
  • Session multiplicity. IdP session, landing-page session, per-portal session, access token, refresh token. Logout has to name which of those it is ending.

The incident: one logout, the rest of the desktop spinning

The business requirement was met for sign-in. Workers authenticated on the landing page and moved between Customer Portal, Worker Portal, and quality-control without a second password. The defect was sign-out.

A case worker logged out of one application — often quality-control or Worker Portal, from a second tab — and continued working in the others. Those shells did not return to the landing page. They sat on a spinner. The APIs behind them returned 500 Internal Server Error.

That combination is almost never “the IdP is down.” It is a distributed session that was torn down on one side and denied, badly, on the other.

What was happening:

Federated logout ran, local logout did not (or the reverse). Logging out of one portal called the IdP (OIDC end_session or SAML SLO) and destroyed the shared identity session — or cleared a gateway session the other apps still used. Sibling SPAs kept their in-memory tokens and cookies and continued to call their APIs.

Resource servers treated a missing principal as a crash. After the IdP or gateway session was gone, the next call arrived without a valid security context. Filters and controllers assumed Authentication was present, then threw. Spring mapped that to 500. The correct response for “this worker is not authenticated” is 401 Unauthorized (or a controlled redirect on a document request). A case API should not 500 because someone logged out of another portal.

The SPA never finished the request. Front-end shells showed a blocking spinner for the in-flight call. There was no timeout, no mapping of 500/401 to “session ended,” and no abort when the tab regained focus. A 500 does not look like login to a retry interceptor, so the spinner never dismissed. To the worker, the other portals were “hung.”

SAML SLO makes this worse when only one service provider is in the logout set. The IdP notifies that SP and ends the IdP session; the other SPs still hold local sessions until their next call. OIDC is quieter until a refresh or userinfo call hits a revoked session and the client ignores the error.

What has to be true for logout

Logout is a platform event, not a button on one front-end app.

  • Local first, then federated. Each portal invalidates its own session and tokens, then the landing page (or a dedicated logout endpoint) completes IdP logout. Do not let a single portal be the only client that knows the end_session URL.
  • Every portal is a logout participant. SAML: SLO metadata for Customer Portal, Worker Portal, quality-control, and the hub. OIDC: back-channel logout or a front-channel flow the landing page owns, plus token revocation if refresh tokens were issued.
  • APIs fail closed on identity, not on exceptions. Missing or expired credentials → 401. Authorization failure → 403. 500 is for faults, not for “the worker signed out elsewhere.”
  • The UI bounds the spinner. On 401, route to the landing page. On timeout or 500 from an auth-dependent call, stop the spinner and offer re-authentication. Do not poll an API that just rejected the session.
  • Do not share one parent-domain session cookie across independently deployed portals unless logout is orchestrated for that cookie. Independent origins with independent cookies, plus a shared IdP, is the strangler-compatible model.

SSO is solved when the worker signs in once. It is done when the worker signs out once — and the other portals become login screens, not 500s with a spinner.

Discussion

Comments from readers and recruiters.

No comments yet. Be the first to leave a note for Subose or for other readers.