How to secure a Java application with SAML (using Spring Boot)

SAML 2.0 is the single sign-on protocol of the enterprise world. Two parties exchange signed XML messages: the identity provider (IdP) authenticates the user (Microsoft Entra ID, Okta, ADFS, Shibboleth, Keycloak, a CAS server…), and the service provider (SP) is your Java application, which receives a signed assertion describing the user.

This guide uses pac4j with Spring Boot to turn a Java web application into a SAML service provider. The demo authenticates against the public pac4j test IdP, and the sections on metadata, attributes and logout show what to adapt for your own IdP.

What you need:

1) Get the Spring Boot demo

The SAML demo project contains the classes shown in this guide, a ready-made keystore and a metadata directory:

git clone --branch saml2 --single-branch https://github.com/pac4j/simple-spring-boot-pac4j-demos.git
cd simple-spring-boot-pac4j-demos

2) Add the Maven dependencies

The demo’s pom.xml uses the Spring Boot parent. On top of Spring MVC, you need the pac4j Spring MVC integration and the SAML module, which embeds the OpenSAML library:

<!-- Spring Boot web -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- pac4j implementation for Spring MVC so for Spring Boot as well -->
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>spring-webmvc-pac4j</artifactId>
    <version>8.0.3</version>
</dependency>
<!-- pac4j support for SAML2 -->
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>pac4j-saml</artifactId>
    <version>6.5.8</version>
</dependency>

3) Create the service provider keystore

A SAML service provider signs its requests and decrypts the assertions it receives, so it needs its own key pair. Generate a Java keystore with keytool:

keytool -genkeypair -alias pac4j-demo -keypass pac4j-demo-passwd -keystore samlKeystore.jks -storepass pac4j-demo-passwd -keyalg RSA -keysize 2048 -validity 3650

Put the file in src/main/resources. The demo already ships one. If the keystore path you configure does not exist and is writable, pac4j generates the keystore and its key pair for you at first use.

4) Configure SAML 2.0 authentication

The whole security setup fits in one class, SecurityConfig:

@Configuration
public class SecurityConfig extends Pac4jSecurityConfig {

    @Value("${app.base-url:http://localhost:8080}")
    private String baseUri;

    @Bean
    public Config config() {
        // configuration of the authentication via the SAML2 protocol
        final var cfg = new SAML2Configuration();
        cfg.getKeystore().setKeystorePath("classpath:samlKeystore.jks");
        cfg.getKeystore().setKeystorePassword("pac4j-demo-passwd");
        cfg.getKeystore().setPrivateKeyPassword("pac4j-demo-passwd");
        cfg.setIdentityProviderMetadataPath("https://www.casserverpac4j.dev/idp/metadata");
        cfg.setServiceProviderEntityId(baseUri + "/callback?client_name=SAML2Client");
        cfg.setServiceProviderMetadataPath("file:metadata/sp-metadata-8080.xml");
        return new Config(baseUri + "/callback", new SAML2Client(cfg));
    }

    @Override
    public void addInterceptors(final InterceptorRegistry registry) {
        // the /protected/** URLs require the SAML2 authentication
        addSecurity(registry, "SAML2Client").addPathPatterns("/protected/**");
    }
}

What each part does:

5) Exchange metadata with your identity provider

SAML trust is mutual: your application trusts the IdP through its metadata, and the IdP must know your application the same way.

When the SAML2Client initializes, pac4j generates the SP metadata into metadata/sp-metadata-8080.xml. This XML document contains your entity ID, your public certificate and your ACS URL, http://localhost:8080/callback?client_name=SAML2Client. Register it at the IdP:

You can also get the same XML programmatically with client.getServiceProviderMetadataResolver().getMetadata(), for instance to serve it from an endpoint.

If the IdP metadata is not reachable over HTTP from your application, download it once and load it from the classpath with cfg.setIdentityProviderMetadataPath("classpath:idp-metadata.xml").

6) Access the authenticated user

The application controller exposes a public page and a protected page:

@Autowired
private ProfileManager profileManager;

@RequestMapping("/")
@ResponseBody
public String index() {
    return "<h1>Public area</h1><p><a href='/protected/index'>Protected area</a></p>"
            + "<p><a href='/logout'>Logout</a></p>" + profileManager.getProfiles();
}

@RequestMapping("/protected/index")
@ResponseBody
public String secure() {
    return "<h1>Protected area</h1><a href='/'>Home</a><p/>"
            + "<p><a href='/logout'>Logout</a></p>" + profileManager.getProfiles();
}

After login, the ProfileManager returns a SAML2Profile. Its identifier is the NameID of the assertion, and its attributes are the SAML attributes the IdP released, under the names the IdP used. Enterprise IdPs often send URNs rather than friendly names, and SAML attributes are multi-valued, so a value comes back as a list:

final var profile = (SAML2Profile) profileManager.getProfile().orElseThrow();
profile.getId();                                            // the NameID
profile.getAttribute("urn:oid:0.9.2342.19200300.100.1.3");  // "mail" in many directories
profile.getAttributes();                                    // everything the IdP sent

To work with readable names in your code, map the attributes once in the configuration:

cfg.setMappedAttributes(Map.of("urn:oid:0.9.2342.19200300.100.1.3", "email"));

The IdP decides which attributes it releases: if a value is missing, the attribute release rule for your SP at the IdP is the place to look.

7) Single logout

The /logout link removes the profile from the local session. SAML also defines a single logout (SLO) that ends the session at the IdP and, through it, at the other applications. Enable it in application.properties:

pac4j.logout.centralLogout=true
pac4j.logout.defaultUrl=/

pac4j then sends a LogoutRequest to the IdP’s single logout endpoint, read from the IdP metadata, and processes the LogoutResponse on the callback URL. To sign outgoing logout requests, add this setting to the SAML2Configuration in step 4 before creating the client:

cfg.setSpLogoutRequestSigned(true);

In pac4j 6.5.8, this option is false by default: enabling central logout alone does not enable request signing. The HTTP-POST and HTTP-Redirect bindings are supported for these messages; pick the one your IdP expects with cfg.setSpLogoutRequestBindingType(...). SLO only works if the IdP metadata declares a SingleLogoutService.

8) Run the application

Start SpringBootDemo from your IDE or with mvn spring-boot:run:

@SpringBootApplication
public class SpringBootDemo {
    public static void main(final String[] args) {
        SpringApplication.run(SpringBootDemo.class, args);
    }
}

Open http://localhost:8080/ and follow Protected area. You are redirected to the IdP to sign in, then posted back to the callback URL with the assertion, and the protected page prints your profile.

If something goes wrong:

Learn more

Using a different integration? The Jakarta EE guide and Spring Security guide explain the integration using OIDC. Follow their “Switching to SAML or CAS” section to use the SAML client configuration from this guide.

Discover more pac4j frameworks and more authentication mechanisms