Skip to main content
the auth layer

SAML 2.0 for Enterprise: Spring Security Configuration and Signature Wrapping Attacks

4 min read Chapter 30 of 45

SAML 2.0 for Enterprise

The Assumption

SAML is legacy but straightforward. Parse the XML, validate the signature, extract the user. The assumption: XML signature validation in your library is correct and complete.

SAML signature wrapping attacks exploit the gap between which XML element is signed and which XML element the application reads. The signature is valid. The assertion the application processes is forged. The library validates correctly. The integration is wrong.

The Attack

XML Signature Wrapping (XSW). A SAML Response contains a signed Assertion with user attributes. The attack moves the signed Assertion to a different location in the XML tree and inserts a forged Assertion at the original location.

<!-- XSW Attack Pattern -->
<samlp:Response>
  <saml:Assertion>  <!-- FORGED, unsigned -->
    <saml:Subject>
      <saml:NameID>[email protected]</saml:NameID>
    </saml:Subject>
  </saml:Assertion>
  <ds:Signature>
    <ds:Reference URI="#_abc123"/>
    <ds:Object>
      <saml:Assertion ID="_abc123">  <!-- Original, signed, hidden -->
        <saml:Subject>
          <saml:NameID>[email protected]</saml:NameID>
        </saml:Subject>
      </saml:Assertion>
    </ds:Object>
  </ds:Signature>
</samlp:Response>

The signature library validates the element with ID="_abc123" (correct). The application reads the first <saml:Assertion> in the DOM (the forged one). The attacker authenticates as [email protected].

Eight variants exist (XSW1 through XSW8), each relocating the signed element differently. A robust defense must handle all of them.

The Spec or Mechanism

Two properties prevent XSW:

  1. After signature validation, process the validated element directly (do not re-query the DOM).
  2. Reject responses with more than one Assertion.

Spring Security’s OpenSaml4AuthenticationProvider satisfies both properties by default. It validates the signature and returns the validated assertion object, not a re-queried DOM element.

The Implementation

Spring Security SAML2 Configuration

@Configuration
@EnableWebSecurity
public class SamlSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .saml2Login(saml2 -> saml2
                .authenticationManager(samlAuthenticationManager())
            )
            .saml2Metadata(Customizer.withDefaults())
            .build();
    }

    @Bean
    public AuthenticationManager samlAuthenticationManager() {
        OpenSaml4AuthenticationProvider provider = new OpenSaml4AuthenticationProvider();

        provider.setResponseValidator(responseToken -> {
            Saml2ResponseValidatorResult result =
                OpenSaml4AuthenticationProvider
                    .createDefaultResponseValidator()
                    .convert(responseToken);

            Response response = responseToken.getResponse();

            // Reject multiple assertions
            if (response.getAssertions().size() != 1) {
                return result.concat(Saml2ResponseValidatorResult.failure(
                    new Saml2Error("invalid_assertion_count",
                        "Expected exactly one assertion, got " +
                        response.getAssertions().size())));
            }

            // Verify issuer matches configured IdP
            String expectedIssuer = responseToken.getToken()
                .getRelyingPartyRegistration()
                .getAssertingPartyDetails()
                .getEntityId();
            String actualIssuer = response.getAssertions().get(0)
                .getIssuer().getValue();

            if (!expectedIssuer.equals(actualIssuer)) {
                return result.concat(Saml2ResponseValidatorResult.failure(
                    new Saml2Error("invalid_issuer",
                        "Assertion issuer does not match expected IdP")));
            }

            return result;
        });

        provider.setAssertionValidator(assertionToken ->
            OpenSaml4AuthenticationProvider
                .createDefaultAssertionValidatorWithParameters(params -> {
                    params.put(SAML2AssertionValidationParameters
                        .VALID_AUDIENCES, Set.of(
                            "https://app.saas.example/saml/metadata"));
                })
                .convert(assertionToken));

        return new ProviderManager(provider);
    }
}

Relying Party Registration

spring:
  security:
    saml2:
      relyingparty:
        registration:
          adfs-globex:
            assertingparty:
              metadata-uri: https://adfs.globex-inc.com/FederationMetadata/2007-06/FederationMetadata.xml
            entity-id: https://app.saas.example/saml/metadata
            acs:
              location: https://app.saas.example/login/saml2/sso/{registrationId}
            signing:
              credentials:
                - private-key-location: classpath:saml/sp-private-key.pem
                  certificate-location: classpath:saml/sp-certificate.pem

SAML to Internal User Mapping

@Component
public class SamlResponseAuthenticationConverter
        implements Converter<ResponseToken, AbstractAuthenticationToken> {

    private final UserRepository userRepository;
    private final TenantProviderMapping tenantMapping;

    @Override
    public AbstractAuthenticationToken convert(ResponseToken responseToken) {
        Response response = responseToken.getResponse();
        Assertion assertion = response.getAssertions().get(0);

        String nameId = assertion.getSubject().getNameID().getValue();
        String registrationId = responseToken.getToken()
            .getRelyingPartyRegistration().getRegistrationId();

        Map<String, List<Object>> attributes = extractAttributes(assertion);
        String email = getFirstAttribute(attributes,
            "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress");

        String tenantId = tenantMapping.resolveTenant(registrationId);

        User user = userRepository
            .findByFederatedIdentity(registrationId, nameId)
            .orElseGet(() -> createUser(email, nameId, registrationId, tenantId));

        return new TenantAwareAuthentication(user, tenantId);
    }

    private Map<String, List<Object>> extractAttributes(Assertion assertion) {
        Map<String, List<Object>> result = new LinkedHashMap<>();
        for (AttributeStatement stmt : assertion.getAttributeStatements()) {
            for (Attribute attr : stmt.getAttributes()) {
                List<Object> values = attr.getAttributeValues().stream()
                    .map(XMLObject::getDOM)
                    .map(Element::getTextContent)
                    .collect(Collectors.toList());
                result.put(attr.getName(), values);
            }
        }
        return result;
    }
}

Vulnerable vs Hardened

// VULNERABLE: Manual XML parsing
public String extractUser(String samlXml) {
    Document doc = parseXml(samlXml);
    validateXmlSignature(doc); // Validates the signed element

    // Re-queries DOM: may find the forged assertion
    NodeList assertions = doc.getElementsByTagNameNS(
        "urn:oasis:names:tc:SAML:2.0:assertion", "Assertion");
    return extractNameId((Element) assertions.item(0));
}
// HARDENED: Spring Security OpenSAML4
// No manual XML parsing. The framework:
// 1. Validates the signature
// 2. Returns the VALIDATED assertion object (not re-queried from DOM)
// 3. Checks assertion count, audience, timestamps, conditions

The Verification

@SpringBootTest
@AutoConfigureMockMvc
class SamlXswDefenseTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void legitimateSamlResponseIsAccepted() throws Exception {
        String samlResponse = createValidSamlResponse(
            "[email protected]", "adfs-globex");

        mockMvc.perform(post("/login/saml2/sso/adfs-globex")
                .param("SAMLResponse", Base64.getEncoder()
                    .encodeToString(samlResponse.getBytes())))
            .andExpect(status().is3xxRedirection());
    }

    @Test
    void xswAttackIsRejected() throws Exception {
        String xswResponse = createXswAttackResponse(
            "[email protected]",  // Forged
            "[email protected]",   // Original signed
            "adfs-globex");

        mockMvc.perform(post("/login/saml2/sso/adfs-globex")
                .param("SAMLResponse", Base64.getEncoder()
                    .encodeToString(xswResponse.getBytes())))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void multipleAssertionsRejected() throws Exception {
        String multiResponse = createMultiAssertionResponse(
            "[email protected]", "[email protected]", "adfs-globex");

        mockMvc.perform(post("/login/saml2/sso/adfs-globex")
                .param("SAMLResponse", Base64.getEncoder()
                    .encodeToString(multiResponse.getBytes())))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void wrongIssuerRejected() throws Exception {
        String wrongIssuer = createSamlResponseWithIssuer(
            "[email protected]",
            "https://evil-idp.example/metadata",
            "adfs-globex");

        mockMvc.perform(post("/login/saml2/sso/adfs-globex")
                .param("SAMLResponse", Base64.getEncoder()
                    .encodeToString(wrongIssuer.getBytes())))
            .andExpect(status().isUnauthorized());
    }
}

The second test validates XSW resistance. If someone introduces a custom SAML processor that re-queries the DOM after validation, this test catches the regression before the vulnerability reaches production.