How to secure a Jakarta EE application with OIDC (using pac4j)

You have a Jakarta EE web application, or a plain servlet application, and you want users to log in through an OpenID Connect (OIDC) provider such as Keycloak, Google, Microsoft Entra ID or Okta. pac4j provides the authentication clients and servlet filters to integrate that login into your application.

This guide adds OIDC authentication to a servlet application with three filters and one configuration class, no Spring, no CDI required. The filters come from the jee-pac4j library, and you can explore a broader example in the jee-pac4j-demo project, which also covers SAML, CAS, OAuth and form login. The protocol part is identical to the Spring Boot guide: what changes is how the security is wired into the servlet container.

What you need:

1) Add the Maven dependencies

Start from a Maven web application with <packaging>war</packaging> and a Java 17 compiler configuration. Two pac4j artifacts are needed: the Jakarta EE integration from jee-pac4j, which provides the filters, and the OpenID Connect module.

<!-- pac4j integration for Jakarta EE (servlet filters) -->
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>jakartaee-pac4j</artifactId>
    <version>8.0.3</version>
</dependency>
<!-- pac4j support for OpenID Connect -->
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>pac4j-oidc</artifactId>
    <version>6.5.8</version>
</dependency>
<!-- the servlet API, provided by your container -->
<dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>6.0.0</version>
    <scope>provided</scope>
</dependency>

For a legacy javax.servlet application, use the javaee-pac4j artifact instead of jakartaee-pac4j, the corresponding javax.servlet-api dependency, javax.servlet imports and a compatible web.xml descriptor. The pac4j configuration follows the same pattern.

2) Write the security configuration

pac4j needs a Config object holding the authentication clients. In a servlet application, you provide it through a ConfigFactory, a class with a single build method that the filters instantiate at startup:

package org.example.security;

import org.pac4j.core.config.Config;
import org.pac4j.core.config.ConfigFactory;
import org.pac4j.oidc.client.OidcClient;
import org.pac4j.oidc.config.OidcConfiguration;

public class SecurityConfigFactory implements ConfigFactory {

    @Override
    public Config build(final Object... parameters) {
        // configuration of the authentication via the OpenID Connect protocol
        final var config = new OidcConfiguration()
            .setDiscoveryURI("https://www.casserverpac4j.dev/oidc/.well-known/openid-configuration")
            .setClientId("myclient")
            .setSecret("mysecret")
            .setAllowUnsignedIdTokens(true);
        return new Config("http://localhost:8080/callback", new OidcClient(config));
    }
}

What each part does:

By default pac4j uses the authorization code flow, with PKCE when the provider supports it. setAllowUnsignedIdTokens(true) only exists because the public demo server issues unsigned ID tokens: remove it for a real provider. To register the application at Keycloak, Google or Entra ID, follow the provider section of the Spring Boot guide: the Java configuration is the same, only the wiring below differs.

3) Declare the filters in web.xml

The integration provides three servlet filters. Declare them in WEB-INF/web.xml:

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
         version="6.0">

    <!-- protects /protected/* with the OIDC login -->
    <filter>
        <filter-name>securityFilter</filter-name>
        <filter-class>org.pac4j.jee.filter.SecurityFilter</filter-class>
        <init-param>
            <param-name>configFactory</param-name>
            <param-value>org.example.security.SecurityConfigFactory</param-value>
        </init-param>
        <init-param>
            <param-name>clients</param-name>
            <param-value>OidcClient</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>securityFilter</filter-name>
        <url-pattern>/protected/*</url-pattern>
    </filter-mapping>

    <!-- finishes the login when the provider redirects the user back -->
    <filter>
        <filter-name>callbackFilter</filter-name>
        <filter-class>org.pac4j.jee.filter.CallbackFilter</filter-class>
        <init-param>
            <param-name>defaultUrl</param-name>
            <param-value>/</param-value>
        </init-param>
        <init-param>
            <param-name>renewSession</param-name>
            <param-value>true</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>callbackFilter</filter-name>
        <url-pattern>/callback</url-pattern>
    </filter-mapping>

    <!-- logs the user out -->
    <filter>
        <filter-name>logoutFilter</filter-name>
        <filter-class>org.pac4j.jee.filter.LogoutFilter</filter-class>
        <init-param>
            <param-name>defaultUrl</param-name>
            <param-value>/</param-value>
        </init-param>
        <init-param>
            <param-name>destroySession</param-name>
            <param-value>true</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>logoutFilter</filter-name>
        <url-pattern>/logout</url-pattern>
    </filter-mapping>

</web-app>

What each filter does:

The configFactory parameter builds the configuration once and shares it with the other pac4j filters, so declaring it on one filter is sufficient. You can declare several SecurityFilter instances with different clients or authorizers for different URL patterns.

Prefer Java to XML? As an alternative to the filter declarations above, register the same filters from a ServletContextListener with the FilterHelper. Use one registration method, so each filter runs only once:

package org.example.security;

import jakarta.servlet.ServletContextEvent;
import jakarta.servlet.ServletContextListener;
import jakarta.servlet.annotation.WebListener;
import org.pac4j.jee.filter.CallbackFilter;
import org.pac4j.jee.filter.LogoutFilter;
import org.pac4j.jee.filter.SecurityFilter;
import org.pac4j.jee.util.FilterHelper;

@WebListener
public class SecurityInitializer implements ServletContextListener {

    @Override
    public void contextInitialized(final ServletContextEvent event) {
        final var config = new SecurityConfigFactory().build();
        final var filters = new FilterHelper(event.getServletContext());
        filters.addFilterMapping("securityFilter", new SecurityFilter(config, "OidcClient"), "/protected/*");
        filters.addFilterMapping("callbackFilter", new CallbackFilter(config, "/"), "/callback");
        final var logoutFilter = new LogoutFilter(config, "/");
        logoutFilter.setDestroySession(true);
        filters.addFilterMapping("logoutFilter", logoutFilter, "/logout");
    }
}

4) Access the authenticated user

In a servlet or a JSP, wrap the request and the response in a JEEContext and read the profile with the ProfileManager:

package org.example.security;

import java.io.IOException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.pac4j.core.profile.ProfileManager;
import org.pac4j.jee.context.JEEContext;
import org.pac4j.jee.context.session.JEESessionStore;
import org.pac4j.oidc.profile.OidcProfile;

@WebServlet("/protected/index")
public class ProtectedServlet extends HttpServlet {

    @Override
    protected void doGet(final HttpServletRequest request, final HttpServletResponse response) throws IOException {
        final var context = new JEEContext(request, response);
        final var manager = new ProfileManager(context, new JEESessionStore());
        final var profile = (OidcProfile) manager.getProfile().orElseThrow();

        response.setContentType("text/plain;charset=UTF-8");
        response.getWriter().println("Hello " + profile.getDisplayName() + " (" + profile.getEmail() + ")");
        response.getWriter().println("Visit /logout to sign out.");
    }
}

The OidcProfile exposes the standard claims as getters, provides the mapped profile attributes through getAttributes(), and gives access to the raw tokens with getIdToken() and getAccessToken(). Which claims are present depends on the scopes: the default is openid profile email.

In a CDI application, the integration can produce a ProfileManager and a WebContext for injection. This requires a CDI-managed Config and injectable HTTP request and response objects; the XML ConfigFactory alone does not supply them. Follow the setup in the jee-pac4j-cdi-demo. Inject the WebContext interface, rather than JEEContext.

5) Logout

The /logout URL mapped above performs the local logout. To also end the session at an identity provider that supports OIDC logout, add centralLogout to the LogoutFilter and replace its existing defaultUrl parameter with an absolute return URL:

<init-param>
    <param-name>centralLogout</param-name>
    <param-value>true</param-value>
</init-param>
<init-param>
    <param-name>defaultUrl</param-name>
    <param-value>http://localhost:8080/</param-value>
</init-param>

pac4j then redirects the browser to the provider’s end_session_endpoint, read from the discovery document. Register http://localhost:8080/ as an allowed post-logout redirect URI at the provider. In production, use your application’s public HTTPS URL.

If you accept a dynamic return URL through the logout request’s url parameter, restrict it with logoutUrlPattern. That pattern validates the requested URL; it does not set the return URL itself.

6) Run the application

Package the application as a WAR:

mvn clean package

Deploy it to your Servlet 6.0 container at the root context on port 8080, matching the URLs above. With Tomcat, deploy the WAR as ROOT.war. For a different context path or port, update the callback URL, the registered redirect URI and the logout return URL accordingly. Provide a public home page, such as src/main/webapp/index.html, for the default redirect after logout.

The jee-pac4j-demo also demonstrates running through a configured Jetty Maven plugin. Its plugin configuration, servlet version and example paths belong together; jetty:run is not available merely by adding the dependencies above.

Open http://localhost:8080/protected/index. You are redirected to the identity provider to sign in, then returned to the protected page, which greets you by name.

If something goes wrong:

Switching to SAML or CAS

Add the corresponding pac4j-saml or pac4j-cas dependency, replace the OidcClient in the ConfigFactory with a SAML2Client or a CasClient, and update the security filter’s clients parameter. Adapt the OidcProfile cast in the servlet to the new profile type, or use the common UserProfile interface. The same filter structure applies, with the callback and logout URLs registered for the chosen protocol. The protocol-specific configuration, the SAML keystore and metadata exchange, and the CAS service registration, are described in the SAML guide and the CAS guide.

Learn more

Discover more pac4j frameworks and more authentication mechanisms