Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Single Sign-On (SSO)

Designing single sign-on across several applications: the trust model, choosing SAML or OIDC per tenant, silent authentication, single logout, and running Keycloak as the broker.

Advanced6 min readUpdated
On this page

SSO means a user authenticates once and reaches every application that trusts the same provider. The mechanics are OIDC or SAML; the design questions are about trust boundaries, tenant federation and logout.

Key Takeaways

  • One provider, many relying parties, all trusting it — that is the entire model.
  • Identity brokering lets each enterprise tenant bring its own provider.
  • Silent authentication (prompt=none) checks for an existing session without a redirect the user sees.
  • Logout needs three parts: local, RP-initiated, and back-channel.
  • Build nothing — use an established provider.

The architecture

Brokering means your applications speak one protocol regardless of what each customer uses. Onboarding a tenant becomes configuration.

This shape is what makes enterprise SSO tractable. Without a broker, every application needs SAML and OIDC support and per-tenant configuration; with one, they each speak OIDC to a single issuer and the broker handles the variety.

The trade-off deserves naming rather than discovering. Concentrating authentication in one broker makes it the single point of failure for every application simultaneously — an outage there is total, not degraded — and the highest-value target in the estate, because compromising it compromises everything downstream at once. Neither is an argument against brokering. Both are arguments for running it with the availability engineering and the scrutiny you would give a payment system.

Keycloak

application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: acme-web
            client-secret: ${KEYCLOAK_CLIENT_SECRET}
            authorization-grant-type: authorization_code
            scope: 'openid,profile,email,roles'
        provider:
          keycloak:
            issuer-uri: 'https://sso.acme.com/realms/acme'
RoleMapping.java
@Bean
GrantedAuthoritiesMapper keycloakAuthoritiesMapper() {
    return authorities -> {
        var mapped = new HashSet<GrantedAuthority>(authorities);
 
        authorities.stream()
                .filter(OidcUserAuthority.class::isInstance)
                .map(OidcUserAuthority.class::cast)
                .findFirst()
                .ifPresent(oidcAuthority -> {
                    // Keycloak nests realm roles under realm_access.roles.
                    Map<String, Object> realmAccess =
                            oidcAuthority.getIdToken().getClaim("realm_access");
                    if (realmAccess != null) {
                        @SuppressWarnings("unchecked")
                        List<String> roles = (List<String>) realmAccess.get("roles");
                        roles.stream()
                             // Map provider roles onto YOUR roles rather than
                             // using them directly — realm roles get renamed.
                             .map(this::toApplicationRole)
                             .filter(Objects::nonNull)
                             .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
                             .forEach(mapped::add);
                    }
                });
        return mapped;
    };
}

Silent authentication

A single-page application loading fresh needs to know whether the user already has a provider session — without a visible redirect if they do not:

silent-auth.js
// Load the authorize endpoint in a hidden iframe with prompt=none.
// If a session exists, it returns a code silently. If not, it returns
// login_required immediately rather than showing a login page in the iframe.
const url = new URL(`${issuer}/protocol/openid-connect/auth`);
url.searchParams.set('client_id', clientId);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('prompt', 'none');
url.searchParams.set('redirect_uri', silentCallbackUri);
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');

Note that third-party cookie restrictions have made iframe-based silent authentication unreliable in several browsers. The modern alternative is a refresh token in an HttpOnly cookie handled by a lightweight backend-for-frontend, which sidesteps the cookie problem entirely.

Single logout

LogoutConfig.java
@Bean
SecurityFilterChain chain(HttpSecurity http,
                          ClientRegistrationRepository registrations) throws Exception {
 
    var logoutHandler = new OidcClientInitiatedLogoutSuccessHandler(registrations);
    logoutHandler.setPostLogoutRedirectUri("{baseUrl}/");
 
    http
        .oauth2Login(Customizer.withDefaults())
        .logout(logout -> logout
            // 1. Local: end this application's session.
            .invalidateHttpSession(true)
            .deleteCookies("SESSION")
            // 2. RP-initiated: redirect to the provider to end its session.
            .logoutSuccessHandler(logoutHandler))
        // 3. Back-channel: the provider POSTs here when the session ends
        //    anywhere, so this app's session ends even if the user logged
        //    out from a different application entirely.
        .oidc(oidc -> oidc.backChannel(Customizer.withDefaults()));
 
    return http.build();
}

All three are needed. Local alone leaves the provider session, so clicking sign-in immediately signs the user back in — a genuine problem on shared machines. RP-initiated handles the application the user is looking at. Back-channel handles the others.

Back-channel logout requires session persistence the provider can address, which is another reason to externalise sessions to Redis.

Per-tenant identity providers

TenantIdpResolver.java
@Service
public class TenantIdpResolver {
 
    /** Home-realm discovery: route the user to their own provider by domain. */
    public String resolveIdpHint(String email) {
        String domain = email.substring(email.indexOf('@') + 1).toLowerCase(Locale.ROOT);
        return tenants.findByEmailDomain(domain)
                .map(Tenant::idpAlias)      // Keycloak identity provider alias
                .orElse(null);              // fall through to the default login
    }
}
Redirect.java
// kc_idp_hint sends the user straight to their own provider, skipping the
// "choose your login method" screen.
String authorizeUrl = "%s/protocol/openid-connect/auth?client_id=%s&kc_idp_hint=%s&..."
        .formatted(issuer, clientId, idpHint);

Discovering the tenant from the email domain is the pattern users expect: they type their work email and land on their own company login page without choosing anything.

It is also a security control, which is easy to miss because it looks like routing. The domain decides which identity provider a user is handed to, so a tenant that manages to register gmail.com — or one that keeps a mapping for a domain it no longer owns — redirects other people's users to a provider it controls. Verify domain ownership before activating a mapping, usually with a DNS TXT record, and re-verify on a schedule, because domains lapse and change hands.

Session lifetimes need aligning across the two layers as well, and they are configured in different places by different people. The provider keeps its own SSO session; each application keeps a local one. If the application session outlives the provider session, a user remains signed in to your service after the identity provider considers them gone — and a deprovisioned account keeps working until that local session expires on its own. Keep application sessions shorter than the SSO session, and treat back-channel logout as what makes revocation prompt rather than eventual.

Provisioning

SSO tells you who someone is. It does not tell you what they should see in your application:

JitProvisioning.java
@Transactional
public User provision(OidcUser oidcUser, String tenantId) {
    return users.findByExternalId(oidcUser.getSubject())
            .map(existing -> {
                // Refresh on every login: a user removed from a directory
                // group should lose that access at their next sign-in, not
                // whenever someone notices.
                existing.syncFrom(oidcUser);
                return existing;
            })
            .orElseGet(() -> users.save(User.provisionFromSso(oidcUser, tenantId)));
}

Just-in-time provisioning creates the local record on first login. It handles joiners well and leavers badly: a user removed from the corporate directory simply stops appearing, and their local record lingers with whatever access it had.

For anything with a compliance requirement, add SCIM. It lets the customer's identity provider push creates, updates and — critically — deactivations to you, which is what an access review asks for.

What to take away

Use an established identity provider and broker to each customer's own where they have one. Map provider roles onto your own rather than consuming them directly. Wire all three logout mechanisms, or sign-out does not work. And plan deprovisioning explicitly — JIT provisioning handles arrivals, not departures.

Frequently Asked Questions

Should I build an identity provider or buy one?
Buy, or self-host something established like Keycloak. An identity provider needs MFA, account recovery, brute-force protection, session management, audit logging, protocol conformance and continuous security maintenance. That is a product, not a component, and building it diverts your best engineers from your actual domain.
How do I support enterprise customers who each use a different identity provider?
Identity brokering. Your application trusts one provider; that provider federates to each customer identity provider — SAML for one, OIDC for another, LDAP for a third. Your application speaks one protocol and onboarding a new tenant becomes configuration rather than code.
Why does logout not work across all my applications?
Local logout only ends the session in the application the user clicked in. You need RP-initiated logout to end the provider session, and back-channel logout so the provider notifies every other application. Without both, the user clicks logout and is silently signed straight back in.

Related tutorials