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.
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
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
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'@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
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
| Check | Attack it stops |
|---|---|
| Signature on the response or assertion | A forged assertion |
Destination matches your ACS URL | An assertion for another service provider replayed at yours |
Conditions NotBefore / NotOnOrAfter | Replay of an old assertion |
AudienceRestriction is your entity id | An assertion issued for a different application |
InResponseTo matches your request id | An unsolicited assertion injected by an attacker |
SubjectConfirmationData Recipient and NotOnOrAfter | Assertion 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
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.0 | OIDC | |
|---|---|---|
| Format | XML | JSON |
| Transport | Browser POST, redirect | HTTP + JWT |
| Mobile and SPA | Awkward | Native |
| Debugging | XML tooling, base64 | Any HTTP client |
| Enterprise adoption | Very high | Growing |
| Complexity | High | Moderate |
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?
Why does my assertion validation fail with a clock error?
Do I have to sign the AuthnRequest?
Related tutorials
- Social Login — Google, GitHub, MicrosoftAdding sign in with Google, GitHub and Microsoft: client registration, mapping provider profiles to your user model, safe account linking, and the onboarding flow afterwards.
- LDAP & Active Directory IntegrationAuthenticating against a corporate directory: LDAP structure, bind versus password comparison, ActiveDirectoryLdapAuthenticationProvider, group-to-role mapping and LDAPS.
- OpenID Connect (OIDC)The identity layer on OAuth 2.0: what an ID token is and how to validate it, standard scopes and claims, discovery, and single logout across relying parties.
- Role-Based Access Control (RBAC)Authorisation with roles: HTTP versus method security, role hierarchies, @PreAuthorize and @PostAuthorize, custom PermissionEvaluator, and where RBAC stops being enough.