Skip to content
JavaAgentic

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

SAML 2.0 Authentication

Enterprise SSO with SAML: the SP-initiated flow step by step, RelyingPartyRegistration, the assertion checks that matter, metadata exchange and single logout.

Expert5 min readUpdated
On this page

SAML is XML-based, verbose and older than most of the stack around it. It is also what a large share of enterprise customers require, so if you sell to enterprises you will implement it.

Key Takeaways

  • Three parties: principal, service provider (you), identity provider.
  • The assertion arrives through the browser, so signature validation is the entire security model.
  • Validate signature, destination, conditions, audience and InResponseTo — all of them.
  • Metadata exchange is how the two sides learn each other's endpoints and certificates.
  • Clock skew is the most common cause of a working integration failing intermittently.

The SP-initiated flow

The assertion travels through the browser, which is why every one of those validation steps is load-bearing.

Because the assertion passes through an untrusted intermediary — the user's browser — the signature is the only thing that makes it trustworthy. Every validation step exists to close a specific attack.

Configuration

application.yml
spring:
  security:
    saml2:
      relyingparty:
        registration:
          okta:
            entity-id: 'https://app.acme.com/saml2/service-provider-metadata/okta'
            assertingparty:
              # Fetch endpoints and certificates from IdP metadata rather than
              # pasting them — pasted certificates go stale on rotation.
              metadata-uri: 'https://acme.okta.com/app/exk1234/sso/saml/metadata'
            signing:
              credentials:
                - private-key-location: 'file:/etc/saml/sp-private.key'
                  certificate-location: 'file:/etc/saml/sp-certificate.crt'
            decryption:
              credentials:
                - private-key-location: 'file:/etc/saml/sp-private.key'
                  certificate-location: 'file:/etc/saml/sp-certificate.crt'
            singlelogout:
              binding: POST
              response-url: '{baseUrl}/logout/saml2/slo'
Saml2Config.java
@Bean
SecurityFilterChain samlChain(HttpSecurity http,
                              RelyingPartyRegistrationRepository registrations) throws Exception {
    var resolver = new DefaultRelyingPartyRegistrationResolver(registrations);
    var metadataFilter = new Saml2MetadataFilter(resolver, new OpenSamlMetadataResolver());
 
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/saml2/**", "/login/**").permitAll()
            .anyRequest().authenticated())
        .saml2Login(saml -> saml
            .authenticationManager(new ProviderManager(customAuthenticationProvider())))
        .saml2Logout(Customizer.withDefaults())
        // Exposes SP metadata for the IdP administrator to import.
        .addFilterBefore(metadataFilter, Saml2WebSsoAuthenticationFilter.class);
 
    return http.build();
}

Fetching the asserting party's metadata by URI rather than pasting a certificate is worth insisting on. Identity providers rotate signing certificates, and a pasted one produces a total outage at a moment nobody controls.

Mapping assertion attributes

SamlAttributeMapping.java
private AuthenticationProvider customAuthenticationProvider() {
    var provider = new OpenSaml4AuthenticationProvider();
 
    provider.setResponseAuthenticationConverter(responseToken -> {
        Saml2Authentication authentication =
                OpenSaml4AuthenticationProvider.createDefaultResponseAuthenticationConverter()
                        .convert(responseToken);
 
        var principal = (Saml2AuthenticatedPrincipal) authentication.getPrincipal();
 
        // Attribute names vary wildly between providers. Azure AD uses long
        // schema URIs; Okta uses short names. Check the actual assertion.
        String email = principal.getFirstAttribute(
                "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress");
        List<String> groups = principal.getAttribute("groups");
 
        var authorities = new ArrayList<GrantedAuthority>();
        if (groups != null) {
            groups.stream()
                  .map(this::mapGroupToRole)      // IdP group -> your role
                  .filter(Objects::nonNull)
                  .forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
        }
 
        User user = users.findOrProvision(email, principal.getName());
        return new Saml2Authentication(
                new AcmeSamlPrincipal(user, principal), authentication.getSaml2Response(), authorities);
    });
 
    return provider;
}

Attribute naming is the practical difficulty. There is no universal convention, so the first step in any integration is capturing a real assertion and reading what the provider actually sends. Do not assume; log the attribute names once in a non-production environment.

Map identity provider groups to your own roles through an explicit table rather than using group names directly as authorities. Corporate directory groups are named for the organisation, not your application, and they get renamed.

What the validations prevent

CheckAttack it stops
Signature on the response or assertionA forged assertion
Destination matches your ACS URLAn assertion for another service provider replayed at yours
Conditions NotBefore / NotOnOrAfterReplay of an old assertion
AudienceRestriction is your entity idAn assertion issued for a different application
InResponseTo matches your request idAn unsolicited assertion injected by an attacker
SubjectConfirmationData Recipient and NotOnOrAfterAssertion relay to a different endpoint

Spring Security performs all of these by default, which is the strongest reason to use it rather than a hand-rolled implementation. XML signature validation in particular has a long history of implementation bugs — signature wrapping attacks, where a valid signature covers a different element than the one being read, defeated several libraries.

Never disable a validation to make an integration work. If InResponseTo fails, the flow is not SP-initiated the way you think; if audience fails, the provider is configured with the wrong entity id. Both are configuration bugs with configuration fixes.

Clock skew

ClockSkew.java
var validator = OpenSaml4AuthenticationProvider.createDefaultAssertionValidator(
        token -> new ValidationContext(Map.of(
                SAML2AssertionValidationParameters.CLOCK_SKEW, Duration.ofMinutes(2))));
provider.setAssertionValidator(validator);

Assertion validity windows are tight — five minutes is typical. A service provider whose clock drifts by six minutes rejects every assertion, and the symptom is an integration that works during testing and fails later, or works for some users and not others.

Run NTP, and allow a small explicit skew. Do not widen the window to hours to make a clock problem go away; that reintroduces the replay risk the window exists to prevent.

SAML or OIDC

SAML 2.0OIDC
FormatXMLJSON
TransportBrowser POST, redirectHTTP + JWT
Mobile and SPAAwkwardNative
DebuggingXML tooling, base64Any HTTP client
Enterprise adoptionVery highGrowing
ComplexityHighModerate

Choose OIDC where you have the choice. Implement SAML because a customer requires it — and many do, because their identity team standardised on it and adding an OIDC application for one vendor is not a conversation they will have.

Supporting both is common: OIDC for self-service and consumer sign-in, SAML for enterprise tenants, with a shared user model behind them.

What to take away

Fetch identity provider metadata by URI so certificate rotation does not break you. Let Spring perform the assertion validations and never disable one to fix an integration. Capture a real assertion to learn the attribute names, map groups to roles explicitly, and keep clocks synchronised — skew is the most common cause of an intermittently broken SAML integration.

Frequently Asked Questions

SAML or OIDC for a new integration?
OIDC if the identity provider supports it — it is simpler, JSON-based, and far easier to debug. SAML when the enterprise customer requires it, which is common: many corporate identity teams standardised on SAML years ago and will not add an OIDC application for one vendor.
Why does my assertion validation fail with a clock error?
SAML conditions carry NotBefore and NotOnOrAfter with tight windows, often five minutes. A service provider whose clock differs from the identity provider by more than the allowed skew rejects valid assertions. Run NTP and configure a small skew tolerance rather than widening the window.
Do I have to sign the AuthnRequest?
Many identity providers require it, and it is good practice regardless — it proves the request came from the registered service provider rather than an attacker initiating a flow. You always need to verify the signature on the response, which is not optional.

Related tutorials