Security: Authentication, Authorization, CSRF, XSS and CSP

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Jakarta Faces 4.1 (API jakarta.faces-api 4.1.2, part of Jakarta EE 11), Eclipse Mojarra 4.1.17 and Apache MyFaces Core 4.1.4, Jakarta Security 4.0 (the Jakarta EE 11 release; 3.0 in Jakarta EE 10), Jakarta Servlet 6.1 and Java 17 or later (the examples were compiled with --release 21), with PrimeFaces 16.0.0 where it is named. Faces 4.0 behaves the same for almost everything below; the differences are collected in What Changed by Version. Every example that is not marked "compiled against the 4.0 API" was deployed in a scratch Bookshelf WAR on Apache Tomcat 11.0.26 with Weld 6.0.4.Final, once with Mojarra and once with MyFaces, and driven with scripted HTTP requests and a headless Chrome page; each status code, header, message and exploit result quoted below was observed in those runs on 2026-10-08. Tomcat does not ship Jakarta Security, so the runs used Eclipse Soteria 3.0.4, the reference implementation of Jakarta Security 3.0 (the newest release on Maven Central on that day); what is specific to Jakarta Security 4.0, and everything that needs an external system (an OpenID Connect provider, a database or LDAP server), comes from the specification and the API and was compiled, not run. Claims taken only from documentation are marked as such. Faces 5.0 is still a milestone release (5.0.0-M9, 2026-09-18) and is described as upcoming, never as current behaviour.

Security is correctness. Every credential, key and secret in this page is a placeholder read from the environment or from the server’s configuration; no listing contains a literal password, client secret or encryption key, and the scratch runs generated their own random values at start-up. Where a well-known bad habit is mentioned, the page says what it breaks and shows the correct form.

Faces does not authenticate anyone. It is a presentation framework that runs inside a servlet container, and the container (with Jakarta Security on top of it) decides who the caller is. What Faces adds is a set of protections that belong to a component framework: escaping on output, a view state that doubles as a request-forgery token (with conditions), protected views, an encrypted client state, and a few APIs for reading the caller and setting cookie attributes. Real security comes from using all the layers, each for what it is good at.

Where Security Lives

A request passes through several layers before a Faces bean runs. A control that sits in an earlier layer cannot be undone by a mistake in a later one, which is why URL access belongs to the container and not to the XHTML.

flowchart TD R[HTTP request] --> A[Container: authentication and security-constraint in web.xml] A -->|denied: 302 to login, 401 or 403| X[Response without the page] A --> F[Servlet filters: headers, same-origin check] F --> S[FacesServlet: Restore View] S -->|view state unknown| V[ViewExpiredException] S -->|protected view without token or from another site| P[ProtectedViewException] S --> L[Apply values, validate, update model] L --> W[f:viewAction and rendered decide what is processed] W --> I[Invoke Application: action method] I --> G[Service layer: role check in the method] G --> E[Render Response: escaping on output, CSP nonce]
Table 1. The layers, in the order a request meets them
Layer Where it is configured What it can stop What it cannot stop

Container authentication and security-constraint

web.xml, Jakarta Security annotations

Anonymous or unauthorised access to a URL, whatever the view does; runs before the filters (see Securing URLs in web.xml)

Anything not covered by a URL pattern, a second route to the same view, methods the constraint does not list

Servlet filters

web.xml filter-mapping

Cross-origin writes, missing headers, caching of private pages

Responses the container produced itself (the login redirect and the 403 carry none of the filter’s headers)

Restore View (view state, protected views)

automatic; faces-config.xml protected-views

Forged postbacks (server state), links followed from other sites (protected views)

Forged postbacks with client state saving or stateless views (see Cross-Site Request Forgery)

rendered and f:viewAction

the XHTML view

Processing of a component or view the caller may not use; the user interface

A caller with a valid view state taken from someone else, unless onPostback is set; direct calls to a service

Service method

CatalogueAdmin with an interceptor (Role Checks in Views and Services)

Anything that reaches the business logic, from a view, a REST resource or a test

Nothing the layer is not asked to check

Encode (Render Response)

components and the escape attribute

Markup injected through data (cross-site scripting)

Data written into a script, an event handler or a URL (see Cross-Site Scripting and the escape Attribute)

The lifecycle position of each Faces control is in the six phases. Two of them matter for security. Restore View is where the view state is looked up and where the view protection check runs, so a forged request is rejected before any of your code sees a value. Invoke Application is where the action method runs; a role check inside the method (or the service it calls) is the last line, and it still holds when every check in the view has been passed or forged.

Authentication Mechanisms in Jakarta Security

Jakarta Security (jakarta.security.enterprise, originally the Java EE Security API of Java EE 8) standardises three things that servers used to configure in proprietary ways: how a caller authenticates (an HttpAuthenticationMechanism), against what (an IdentityStore) and how the application asks (SecurityContext). Each built-in mechanism is switched on by an annotation on any CDI-managed class of the application; the container then registers an @ApplicationScoped bean for it. The specification lists four:

Table 2. Built-in authentication mechanisms (Jakarta Security 4.0 specification, 2.4)
Annotation Behaviour Since

@BasicAuthenticationMechanismDefinition

HTTP Basic: the browser asks for credentials and sends them on every request. Not a Faces login; useful for REST endpoints next to the views

1.0

@FormAuthenticationMechanismDefinition

Servlet FORM login: a login page with a plain HTML form that posts j_username and j_password to j_security_check

1.0

@CustomFormAuthenticationMechanismDefinition

The same login-to-continue flow, but the application collects the credentials itself (a Faces form with validation, messages, Ajax) and calls SecurityContext.authenticate()

1.0

@OpenIdAuthenticationMechanismDefinition

OpenID Connect Authorization Code flow with refresh tokens: the login page is the provider’s

3.0

Three rules from the specification shape everything below.

  • The annotation replaces login-config. If the application has an HttpAuthenticationMechanism and also a login-config in web.xml, the container may fail the deployment and otherwise must use only the mechanism. Delete login-config once you move to the annotations.

  • The container resolves roles, not you. A caller who belongs to group foo has role foo by default (specification 1.2.1), so the groups an identity store returns are the role names that security-constraint, isUserInRole and SecurityContext.isCallerInRole see. Servers may override the mapping with proprietary configuration.

  • Jakarta Security 4.0 added qualifiers on the built-in beans and a basic API (HttpAuthenticationMechanismHandler) for applications that need more than one mechanism, and the in-memory identity store. Before 4.0 an application was limited to one mechanism.

Jakarta Security is part of Jakarta EE 11 (Web Profile and Platform), so GlassFish, WildFly, Payara, Open Liberty and TomEE provide an implementation; a bare servlet container such as Tomcat does not, and Eclipse Soteria (the reference implementation) can be added to the WAR. The next sections work with both flavours of form login because they are the ones a Faces developer meets first.

The FORM Mechanism With a Faces Login Page

The FORM mechanism is the Servlet form login: the container shows loginPage when an anonymous caller asks for a protected URL, the page posts j_username and j_password to j_security_check, and the container validates them (here against an identity store), sends the caller to the page that was originally requested or to errorPage. The login page is an ordinary Facelets view, but it must contain a plain HTML form: h:form renders a different action and prefixes the field names with the form id, so the container would never recognise them.

SecurityConfig.java — the FORM mechanism
package org.example.bookshelf.security;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.security.enterprise.authentication.mechanism.http.FormAuthenticationMechanismDefinition;
import jakarta.security.enterprise.authentication.mechanism.http.LoginToContinue;

@FormAuthenticationMechanismDefinition(
        loginToContinue = @LoginToContinue(
                loginPage = "/login-form.xhtml",
                errorPage = "/login-error.xhtml"))
@ApplicationScoped
public class SecurityConfig {
}
login-form.xhtml — a Facelets page with a plain HTML form
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:h="jakarta.faces.html">
<h:head><title>Sign in</title></h:head>
<h:body>
    <h1>Sign in</h1>
    <form method="post" action="j_security_check">
        <label for="u">User name</label>
        <input id="u" type="text" name="j_username" autocomplete="username"/>
        <label for="p">Password</label>
        <input id="p" type="password" name="j_password" autocomplete="current-password"/>
        <input type="submit" value="Sign in"/>
    </form>
</h:body>
</html>

Observed on Tomcat 11.0.26 with Soteria 3.0.4: an anonymous GET /members/shelf.xhtml answered 200 with the login page at the protected URL (useForwardToLogin is true by default, so the container forwards instead of redirecting); a post with a wrong password answered 302 to /login-error.xhtml; a post with the right one answered 302 to /members/shelf.xhtml, the page that had been requested.

Use FORM when the login page is static and needs nothing from Faces: no validation messages, no Ajax, no bean. The page is then a plain template. As soon as the login form should behave like the rest of the application, use the custom form below.

The Custom FORM Mechanism: A Faces Login Form

@CustomFormAuthenticationMechanismDefinition keeps the login-to-continue behaviour (the container remembers the requested URL, sends the caller to loginPage, and brings them back after a successful login) but lets the application collect the credentials. The specification says it was designed to fit CDI, Jakarta Expression Language, Jakarta Validation "and specifically Jakarta Faces": the login page is a normal Faces form whose bean calls SecurityContext.authenticate() with the credentials. The result is a jakarta.security.enterprise.AuthenticationStatus:

Table 3. What authenticate() returns and what the bean does with it
Status Meaning In the action method

SEND_CONTINUE

The authentication succeeded and the mechanism has sent (or will send) a redirect to the page the caller asked for first

Call facesContext.responseComplete() so Faces does not render a second response

SUCCESS

The authentication succeeded and nothing was saved to continue to (the caller opened the login page directly)

Return a navigation outcome to a fixed landing page

SEND_FAILURE

The credentials were rejected

Add one generic message and re-render the form

NOT_DONE

The mechanism did not run for this request

Return null; this is a configuration problem, not a wrong password

SecurityConfig.java — the custom FORM mechanism, no login-config in web.xml
package org.example.bookshelf.security;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.security.enterprise.authentication.mechanism.http.CustomFormAuthenticationMechanismDefinition;
import jakarta.security.enterprise.authentication.mechanism.http.LoginToContinue;

@CustomFormAuthenticationMechanismDefinition(
        loginToContinue = @LoginToContinue(
                loginPage = "/login.xhtml",
                errorPage = "",
                useForwardToLogin = false))
@ApplicationScoped
public class SecurityConfig {
}
login.xhtml — the Faces login page
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core"
      xmlns:pt="jakarta.faces.passthrough">
<h:head><title>Sign in</title></h:head>
<h:body>
    <h1>Sign in</h1>
    <h:messages id="msgs" globalOnly="true"/>
    <h:form id="login" prependId="true">
        <h:outputLabel for="user" value="User name"/>
        <h:inputText id="user" value="#{loginBean.username}" required="true"
                     pt:autocomplete="username" pt:autocapitalize="none"/>
        <h:outputLabel for="pw" value="Password"/>
        <h:inputSecret id="pw" value="#{loginBean.password}" required="true"
                       redisplay="false" pt:autocomplete="current-password"/>
        <h:commandButton id="go" value="Sign in" action="#{loginBean.login}"/>
    </h:form>
</h:body>
</html>
LoginBean.java
package org.example.bookshelf.web;

import static jakarta.security.enterprise.authentication.mechanism.http.AuthenticationParameters.withParams;

import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.ExternalContext;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.security.enterprise.AuthenticationStatus;
import jakarta.security.enterprise.SecurityContext;
import jakarta.security.enterprise.credential.Password;
import jakarta.security.enterprise.credential.UsernamePasswordCredential;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Named
@RequestScoped
public class LoginBean {

    private String username;
    private String password;

    @Inject
    SecurityContext securityContext;

    @Inject
    FacesContext facesContext;

    public String login() {
        ExternalContext external = facesContext.getExternalContext();
        AuthenticationStatus status = securityContext.authenticate(
                (HttpServletRequest) external.getRequest(),
                (HttpServletResponse) external.getResponse(),
                withParams().credential(new UsernamePasswordCredential(username, new Password(password))));
        password = null;
        switch (status) {
            case SEND_CONTINUE -> {
                // the mechanism has already sent a redirect to the page the caller asked for first
                facesContext.responseComplete();
                return null;
            }
            case SEND_FAILURE -> {
                // one message for "unknown user" and "wrong password": do not reveal which accounts exist
                facesContext.addMessage(null, new FacesMessage(FacesMessage.SEVERITY_ERROR,
                        "Invalid user name or password", null));
                return null;
            }
            case SUCCESS -> {
                // logged in directly from the login page: go to a fixed landing page
                return "/index.xhtml?faces-redirect=true";
            }
            default -> {
                return null;
            }
        }
    }

    public String logout() throws ServletException {
        ExternalContext external = facesContext.getExternalContext();
        ((HttpServletRequest) external.getRequest()).logout();
        external.invalidateSession();
        return "/login.xhtml?faces-redirect=true";
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

The page is small on purpose, because each line removes a mistake:

  • errorPage = "": failures are reported by the form itself (h:messages), so the user keeps the typed user name. The default errorPage is /login-error, and the specification redirects there only when the value is non-empty.

  • useForwardToLogin = false makes the mechanism redirect to the login page. With the default (true) the container forwards, so the address bar shows the protected URL while the login page is displayed.

  • h:inputSecret redisplay="false" (the default) never writes the password back into the HTML, and the bean sets password = null as soon as it has used it. The bean is @RequestScoped: a password in a @ViewScoped or @SessionScoped bean would live in the session or in the view state (and, with client state saving, in the page).

  • pt:autocomplete="current-password" and username tell the browser’s password manager what the fields are (HTML5 pass-through attributes, see HTML5-friendly markup); this makes strong, unique passwords practical for users.

  • One message for both failures. The scratch run answered a wrong password for an existing user and an unknown user with the identical page, on both implementations.

  • The SUCCESS branch returns a fixed outcome. Never redirect to a URL taken from a request parameter without checking it (see Redirects after login).

Observed on Tomcat with Soteria 3.0.4, Mojarra and MyFaces alike: an anonymous request for /members/shelf.xhtml answered 302 to /login.xhtml; the login post with the right password answered 302 to /members/shelf.xhtml and the session cookie changed (see Session Fixation and Logout); a login from the login page itself answered 302 to /index.xhtml (the SUCCESS branch); the wrong password re-rendered the form with the generic message.

sequenceDiagram autonumber participant B as Browser participant C as Container and mechanism participant F as FacesServlet and LoginBean participant S as IdentityStore B->>C: GET /members/shelf.xhtml (anonymous) C-->>B: 302 to /login.xhtml (the request is saved) B->>F: GET /login.xhtml F-->>B: login form with jakarta.faces.ViewState B->>F: POST /login.xhtml (user, password, view state) F->>C: securityContext.authenticate(request, response, credential) C->>S: validate(UsernamePasswordCredential) S-->>C: VALID with groups, or INVALID C-->>B: SEND_CONTINUE: 302 to /members/shelf.xhtml, new session id B->>C: GET /members/shelf.xhtml (new cookie)

Two limitations of the Tomcat scratch setup, neither a property of Faces. Soteria registers its authentication module when the application starts, and on embedded Tomcat that depended on the start-up order: the runs needed META-INF/context.xml to bind the BeanManager into JNDI (the standard Weld-on-Tomcat setup) and, in the MyFaces run, a small listener that registers the same module when Soteria’s own installer had not. On a Jakarta EE server none of this exists. And SecurityContext.getCallerPrincipal() and isCallerInRole() failed on Tomcat with IllegalArgumentException: unknown handler key (Soteria asks the Jakarta Authorization policy context for the caller’s Subject, and Tomcat has no such provider), so the scratch bean read the caller from ExternalContext, which the specification lists as equivalent (4.5).

Identity Stores and Password Hashes

An IdentityStore answers one question, "are these credentials valid, and which groups does the caller have?". The mechanism never touches the database; it hands the credential to the IdentityStoreHandler, which asks every enabled store in order of priority() (lower first) and combines the answers. A store implements validate(Credential) (usually through a method taking a concrete credential type, here UsernamePasswordCredential), getCallerGroups(...) for stores that only provide groups, priority() and validationTypes().

PasswordService.java — the one place that hashes
package org.example.bookshelf.security;

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.security.enterprise.identitystore.Pbkdf2PasswordHash;
import java.util.Map;

/** The one place that knows how passwords are hashed: PBKDF2 with HMAC-SHA-256 and the OWASP work factor, not the 2048 iterations of the default. */
@ApplicationScoped
public class PasswordService {

    @Inject
    Pbkdf2PasswordHash hasher;

    @PostConstruct
    void configure() {
        hasher.initialize(Map.of(
                "Pbkdf2PasswordHash.Algorithm", "PBKDF2WithHmacSHA256",
                "Pbkdf2PasswordHash.Iterations", "600000"));
    }

    /** Returns the encoded form: algorithm:iterations:base64(salt):base64(hash). */
    public String hash(char[] password) {
        return hasher.generate(password);
    }

    public boolean matches(char[] password, String storedHash) {
        return hasher.verify(password, storedHash);
    }
}
BookshelfIdentityStore.java
package org.example.bookshelf.security;

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.security.enterprise.credential.UsernamePasswordCredential;
import jakarta.security.enterprise.identitystore.CredentialValidationResult;
import jakarta.security.enterprise.identitystore.IdentityStore;
import java.util.UUID;

@ApplicationScoped
public class BookshelfIdentityStore implements IdentityStore {

    @Inject
    AccountRepository accounts;

    @Inject
    PasswordService passwords;

    /** Hash of a random password nobody knows. Verified when the user does not exist, so both paths cost the same. */
    private String decoyHash;

    @PostConstruct
    void init() {
        decoyHash = passwords.hash(UUID.randomUUID().toString().toCharArray());
    }

    /** Called by the IdentityStoreHandler for UsernamePasswordCredential. */
    public CredentialValidationResult validate(UsernamePasswordCredential credential) {
        var account = accounts.find(credential.getCaller());
        String hash = account.map(Account::passwordHash).orElse(decoyHash);
        boolean valid = passwords.matches(credential.getPassword().getValue(), hash);
        if (account.isPresent() && valid) {
            return new CredentialValidationResult(account.get().username(), account.get().groups());
        }
        return CredentialValidationResult.INVALID_RESULT;
    }
}
AccountRepository.java and Account.java — only the hash is stored
package org.example.bookshelf.security;

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Stand-in for a JPA repository. The demo accounts are seeded from the environment, so no password
 * is ever written into the source: BOOKSHELF_SEED_ADMIN_PASSWORD and BOOKSHELF_SEED_MEMBER_PASSWORD.
 */
@ApplicationScoped
public class AccountRepository {

    private final Map<String, Account> accounts = new ConcurrentHashMap<>();

    @Inject
    PasswordService passwords;

    @PostConstruct
    void seed() {
        register("admin", System.getenv("BOOKSHELF_SEED_ADMIN_PASSWORD"), Set.of("ADMIN", "MEMBER"));
        register("mira", System.getenv("BOOKSHELF_SEED_MEMBER_PASSWORD"), Set.of("MEMBER"));
    }

    /** Only the hash is kept. */
    public void register(String username, String password, Set<String> groups) {
        if (password != null && !password.isEmpty()) {
            accounts.put(username, new Account(username, passwords.hash(password.toCharArray()), groups));
        }
    }

    public Optional<Account> find(String username) {
        return Optional.ofNullable(accounts.get(username));
    }
}

package org.example.bookshelf.security;

import java.util.Set;

/** A stored account: the password is never kept, only the encoded PBKDF2 hash. */
public record Account(String username, String passwordHash, Set<String> groups) {
}

What the listings do, and why:

  • Passwords are hashed with a slow, salted function and never stored or logged. Pbkdf2PasswordHash is the implementation every server must provide. Its encoded form is <algorithm>:<iterations>:<base64 salt>:<base64 hash> (observed: four colon-separated parts, Base64 salt and hash of 44 characters each). The algorithm and the iteration count travel inside the string, so the API states that the configuration can change without invalidating stored hashes (observed: a hash generated with 600,000 iterations verified on an instance left at the defaults). Raise the work factor over time and rehash a password at the next successful login.

  • The default work factor is far too low. The API documents 2048 iterations and PBKDF2WithHmacSHA256 as the defaults, with a minimum of 1024. The OWASP Password Storage Cheat Sheet recommends 600,000 iterations for PBKDF2-HMAC-SHA256. The listing sets both explicitly through the documented parameters Pbkdf2PasswordHash.Algorithm, Pbkdf2PasswordHash.Iterations, Pbkdf2PasswordHash.SaltSizeBytes and Pbkdf2PasswordHash.KeySizeBytes. On the scratch machine (JDK 26) one hash at 600,000 iterations took about 120 ms; measure on production hardware and keep a login under a quarter of a second. (OWASP prefers Argon2id when a library for it is acceptable; the standard API offers PBKDF2 only, and you can implement PasswordHash yourself.)

  • The injected Pbkdf2PasswordHash is dependent-scoped, so every injection point is a separate instance that must be initialised; PasswordService holds the single configured instance.

  • char[], not String. Password.getValue() returns the characters, and Password.clear() and UsernamePasswordCredential.clearCredential() overwrite them, so a password does not have to linger as an immutable String.

  • Unknown users cost the same as wrong passwords. The store verifies against a throw-away hash when the account does not exist, so the response time does not reveal which names exist (eight runs each, including the two HTTP requests: median 81 ms for a wrong password, 76 ms for an unknown user).

  • The seeds come from the environment. AccountRepository is the stand-in for a JPA repository (see the persistence page); the demo accounts exist only when BOOKSHELF_SEED_ADMIN_PASSWORD and BOOKSHELF_SEED_MEMBER_PASSWORD are set. A real deployment creates accounts through a registration or administration use case that calls register, and never ships a default account.

The container also provides three built-in stores, configured by annotation on any CDI class. Each is an @ApplicationScoped bean with the @Default qualifier.

DatabaseStoreConfig.java — the built-in database store with explicit hash parameters (compiled against the 4.0 API)
package org.example.bookshelf.security;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.security.enterprise.identitystore.DatabaseIdentityStoreDefinition;
import jakarta.security.enterprise.identitystore.Pbkdf2PasswordHash;

@DatabaseIdentityStoreDefinition(
        dataSourceLookup = "java:app/jdbc/bookshelf",
        callerQuery = "select password_hash from account where username = ?",
        groupsQuery = "select group_name from account_group where username = ?",
        hashAlgorithm = Pbkdf2PasswordHash.class,
        hashAlgorithmParameters = {
                "Pbkdf2PasswordHash.Algorithm=PBKDF2WithHmacSHA256",
                "Pbkdf2PasswordHash.Iterations=600000",
                "Pbkdf2PasswordHash.SaltSizeBytes=32",
                "Pbkdf2PasswordHash.KeySizeBytes=32"})
@ApplicationScoped
public class DatabaseStoreConfig {
}

The caller query returns the stored password hash and the groups query returns the group names; both take the caller name as the one placeholder, and the first column of the result is used. The data source is looked up in JNDI: its user and password are configured in the server, not in the WAR. @LdapIdentityStoreDefinition takes bindDn and bindDnPassword; both attributes accept Jakarta Expression Language, and the API says so for the OpenID client secret, which applies equally here: let a configuration bean supply the value, never the annotation.

LdapStoreConfig.java — the bind password is an expression, never a literal (compiled against the 4.0 API)
package org.example.bookshelf.security;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.security.enterprise.identitystore.LdapIdentityStoreDefinition;

@LdapIdentityStoreDefinition(
        url = "${ldapConfig.url}",
        bindDn = "${ldapConfig.bindDn}",
        bindDnPassword = "${ldapConfig.bindPassword}",
        callerBaseDn = "ou=people,dc=example,dc=org",
        callerNameAttribute = "uid",
        groupSearchBase = "ou=groups,dc=example,dc=org",
        groupNameAttribute = "cn",
        groupMemberAttribute = "member")
@ApplicationScoped
public class LdapStoreConfig {
}

Jakarta Security 4.0 added @InMemoryIdentityStoreDefinition, which keeps the accounts, with their passwords, in the annotation. The specification says it is for "testing, debugging, demos" and "not generally advised to be used in production", and allows a container to warn about it or refuse to start. Do not use it for anything that holds real accounts, and never commit a real password into the annotation.

Remember me. @RememberMe on an authentication mechanism, together with a RememberMeIdentityStore that the application provides, issues a login-token cookie so that the caller is not asked again. The specification’s defaults are one day (cookieMaxAgeSeconds), Secure (cookieSecureOnly), HttpOnly (cookieHttpOnly) and the cookie name JREMEMBERMEID; the mechanism removes the cookie and calls removeLoginToken on logout. Store only a hash of a random token on the server, and do not use the cookie for anything else. (Documentation only; not run.)

OpenID Connect

@OpenIdAuthenticationMechanismDefinition (Jakarta Security 3.0 and later) turns the application into an OpenID Connect relying party that uses the Authorization Code flow: the mechanism redirects the caller to the provider, receives the code at the redirect URI, exchanges it for tokens at the provider’s token endpoint with the client secret, validates the ID token (issuer, audience, signature, expiry, and the nonce when useNonce is on) and takes the caller name and groups from claims. There is no login page and no password in the application.

OpenIdConfig.java and OidcSecurityConfig.java — every provider setting comes from the environment (compiled against the 4.0 API)
package org.example.bookshelf.security;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Named;

/** Reads the provider settings from the environment; nothing is hard-coded and nothing secret is in the WAR. */
@Named("openIdConfig")
@ApplicationScoped
public class OpenIdConfig {

    public String getIssuerUri() {
        return System.getenv("BOOKSHELF_OIDC_ISSUER");
    }

    public String getClientId() {
        return System.getenv("BOOKSHELF_OIDC_CLIENT_ID");
    }

    public String getClientSecret() {
        return System.getenv("BOOKSHELF_OIDC_CLIENT_SECRET");
    }
}

package org.example.bookshelf.security;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.security.enterprise.authentication.mechanism.http.OpenIdAuthenticationMechanismDefinition;
import jakarta.security.enterprise.authentication.mechanism.http.openid.ClaimsDefinition;
import jakarta.security.enterprise.authentication.mechanism.http.openid.LogoutDefinition;
import jakarta.security.enterprise.authentication.mechanism.http.openid.PromptType;

@OpenIdAuthenticationMechanismDefinition(
        providerURI = "${openIdConfig.issuerUri}",
        clientId = "${openIdConfig.clientId}",
        clientSecret = "${openIdConfig.clientSecret}",
        redirectURI = "${baseURL}/callback",
        scope = {"openid", "email", "profile"},
        useNonce = true,
        useSession = true,
        redirectToOriginalResource = true,
        tokenAutoRefresh = true,
        claimsDefinition = @ClaimsDefinition(callerNameClaim = "email", callerGroupsClaim = "groups"),
        logout = @LogoutDefinition(notifyProvider = true, redirectURI = "${baseURL}/"))
@ApplicationScoped
public class OidcSecurityConfig {
}

Settings that matter, all from the specification and the API:

  • providerURI is the issuer; /.well-known/openid-configuration is appended to discover the endpoints. clientId and clientSecret are required. The API says of the secret that it is "strongly recommended to set this using an Expression so that the value is not hardcoded", which is what the listing does; the environment variable is read by a bean, so the secret never appears in the annotation, the WAR or the repository.

  • redirectURI defaults to ${baseURL}/Callback (baseURL is the application’s host and context path). The provider must have the exact URI registered; a wildcard registration at the provider is an open redirect (see exact redirect-URI matching).

  • scope defaults to openid, email and profile. Ask for the least that the application needs.

  • useNonce defaults to true; keep it. useSession defaults to true (state and nonce in the HTTP session); with false the specification requires a cookie that is HttpOnly and Secure.

  • claimsDefinition chooses which claims name the caller and give the groups; logout with notifyProvider = true calls the provider’s end-session endpoint so that signing out of the application also signs out of the provider.

  • tokenAutoRefresh refreshes expired tokens with the refresh token; redirectToOriginalResource restores the request that triggered the login, like the login-to-continue of the form mechanisms.

After login the bean OpenIdContext (session scoped, in jakarta.security.enterprise.identitystore.openid) gives access to the subject, the tokens and the claims:

ProfileBean.java — reading the claims in a Faces bean (compiled against the 4.0 API)
package org.example.bookshelf.web;

import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.security.enterprise.identitystore.openid.OpenIdContext;

@Named
@RequestScoped
public class ProfileBean {

    @Inject
    OpenIdContext openId;

    /** The "sub" claim: the provider's stable identifier of the user. */
    public String getSubject() {
        return openId.getSubject();
    }

    /** A claim from the ID token; absent when the provider did not send it. */
    public String getEmail() {
        return openId.getClaims().getEmail().orElse("");
    }
}

The OpenID Connect annotation was compiled but not run: it needs a provider, and the scratch environment has none. The behaviour above is the specification’s. The OAuth section of this site explains the protocol itself: the Authorization Code flow with PKCE, ID tokens versus access tokens and logout and session management. Faces needs nothing special for OpenID Connect: the pages are protected by the same security-constraint elements, rendered reads the same roles, and the access token should never be written into a view.

Role Checks in Views and Services

Once the caller is authenticated, four APIs answer "who is this and what may they do". All four see the same container identity; they differ in how much they need.

Table 4. Reading the caller (observed values are for the member account mira on Mojarra and MyFaces)
Expression or call Where it works Observed

#{request.isUserInRole('ADMIN')}

any Facelets expression (request is one of the implicit objects; the call is an EL 3 method call with a parameter)

false for the member, true for the administrator; the same on both implementations

#{request.remoteUser}

any Facelets expression

mira; empty for an anonymous request

externalContext.isUserInRole(role), getUserPrincipal(), getRemoteUser()

Java code that already has a FacesContext

identical to the request methods

SecurityContext.isCallerInRole(role), getCallerPrincipal(), getAllDeclaredCallerRoles(), hasAccessToWebResource(url, methods...)

any CDI bean (@Inject SecurityContext), including service classes that know nothing about Faces

Not runnable on the Tomcat scratch setup (see the custom FORM section); on a Jakarta EE server the specification requires the same answer as the Servlet methods. hasAccessToWebResource evaluates the security-constraint elements for the current caller, so a view can ask "may this caller open /admin/panel?" without repeating the rule

rendered is the tool for the user interface. A component whose rendered expression is false produces no markup, and Faces evaluates the expression again when it processes the postback and skips the components that are not rendered, so a command that is not rendered is never decoded and never queues an action event:

index.xhtml (excerpt) — the administrator’s button depends on the role
<p id="role">isUserInRole(ADMIN)=#{request.isUserInRole('ADMIN')} MEMBER=#{facesContext.externalContext.isUserInRole('MEMBER')} remoteUser=#{request.remoteUser}</p>
<h:form id="f">
    <h:commandButton id="adminBtn" value="Delete withdrawn titles" action="#{adminBean.delete}"
                     rendered="#{request.isUserInRole('ADMIN')}"/>
    <h:commandButton id="guardedBtn" value="Delete withdrawn titles (always rendered)" action="#{adminBean.delete}"/>
    <h:commandButton id="rotate" value="Rotate session id" action="#{sessionHygieneBean.rotate}"/>
    <h:commandButton id="logout" value="Sign out" action="#{loginBean.logout}"/>
</h:form>

What a hidden button is worth, measured by posting the form by hand as the member with the field names of both buttons (scripted HTTP, view state taken from the member’s own page):

Table 5. Forged postbacks as a member (the administrator’s button is rendered only for administrators)
Forged request Result on Mojarra and MyFaces Why

Post f:adminBtn, the button that is not rendered for the member

nothing happened, no message, the counter stayed at 0

The component does not exist in the member’s tree; the parameter is ignored

Post f:guardedBtn, a button that is always rendered, whose action calls the service

"denied by service"; the counter stayed at 0

The @RolesRequired("ADMIN") interceptor on the service refused the call

The administrator presses f:adminBtn

"deleted, count=1"

Allowed everywhere

So rendered is a good filter on what the user can do from a view, and it fails closed in this test, but it is a statement about the view, not about the operation. The business method is reached from other places (a REST resource, a scheduled job, the next developer’s page), and a button that is always rendered is a single typing mistake away. The service enforces its own rule:

RolesRequired.java — an interceptor binding (names the roles allowed)
package org.example.bookshelf.security;

import jakarta.enterprise.util.Nonbinding;
import jakarta.interceptor.InterceptorBinding;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.annotation.ElementType;

/** The caller must be in at least one of the listed roles, whatever the view layer shows. */
@InterceptorBinding
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RolesRequired {
    @Nonbinding
    String[] value();
}
RolesRequiredInterceptor.java
package org.example.bookshelf.security;

import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays;

@RolesRequired({})
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class RolesRequiredInterceptor {

    @Inject
    HttpServletRequest request;

    @AroundInvoke
    public Object check(InvocationContext context) throws Exception {
        RolesRequired required = context.getMethod().getAnnotation(RolesRequired.class);
        if (required == null) {
            required = context.getMethod().getDeclaringClass().getAnnotation(RolesRequired.class);
        }
        boolean allowed = required != null && Arrays.stream(required.value()).anyMatch(request::isUserInRole);
        if (!allowed) {
            throw new SecurityException("Caller is not allowed to call " + context.getMethod().getName());
        }
        return context.proceed();
    }
}
CatalogueAdmin.java and AdminBean.java
package org.example.bookshelf.service;

import jakarta.enterprise.context.ApplicationScoped;
import java.util.concurrent.atomic.AtomicInteger;
import org.example.bookshelf.security.RolesRequired;

/** Stand-in for the service that removes withdrawn titles; it only counts the calls that were allowed. */
@ApplicationScoped
public class CatalogueAdmin {

    private final AtomicInteger calls = new AtomicInteger();

    @RolesRequired("ADMIN")
    public int deleteAll() {
        return calls.incrementAndGet();
    }

    public int deletes() {
        return calls.get();
    }
}

package org.example.bookshelf.web;

import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.example.bookshelf.service.CatalogueAdmin;

@Named
@RequestScoped
public class AdminBean {

    @Inject
    CatalogueAdmin catalogue;

    @Inject
    FacesContext facesContext;

    public String delete() {
        try {
            int calls = catalogue.deleteAll();
            facesContext.addMessage(null, new FacesMessage("deleted, count=" + calls));
        } catch (SecurityException e) {
            // the service refused: the view must not be the only place that knows the rule
            facesContext.addMessage(null, new FacesMessage(FacesMessage.SEVERITY_ERROR, "denied by service", null));
        }
        return null;
    }
}

The interceptor asks the injected HttpServletRequest (a built-in bean in a servlet container) so that it runs unchanged on Tomcat; on a Jakarta EE server inject SecurityContext and call isCallerInRole instead. Jakarta Annotations defines @RolesAllowed, which enterprise bean containers enforce; whether a server also enforces it on a plain CDI bean is server-specific, which is why the example uses its own binding that works wherever interceptors do.

View-level guard. A whole view can refuse a caller with f:viewAction, which calls a method before the view is rendered. The view action does not run on a postback unless asked, and that matters:

AccessGuard.java and guarded.xhtml
package org.example.bookshelf.web;

import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.context.ExternalContext;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.IOException;

@Named
@RequestScoped
public class AccessGuard {

    @Inject
    FacesContext facesContext;

    /** Called from f:viewAction: answer 403 and stop the lifecycle when the caller lacks the role. */
    public void require(String role) throws IOException {
        ExternalContext external = facesContext.getExternalContext();
        if (!external.isUserInRole(role)) {
            external.responseSendError(403, "Forbidden");
            facesContext.responseComplete();
        }
    }
}
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:h="jakarta.faces.html" xmlns:f="jakarta.faces.core">
<f:metadata><f:viewAction action="#{accessGuard.require('ADMIN')}" onPostback="true"/></f:metadata>
<h:head><title>Guarded</title></h:head>
<h:body><h:form id="f"><h:commandButton id="b" value="inc" action="#{cspBean.inc}"/><h:outputText id="o" value="count=#{cspBean.count}"/></h:form></h:body>
</html>

The VDL says: "view actions do not operate on postback, by default", and onPostback="true" enables it. The difference was measured with client state saving (the state travels in the page, see Cross-Site Request Forgery): an administrator opened /guarded.xhtml, a member posted that administrator’s jakarta.faces.ViewState to the same URL with the member’s own session cookie.

Table 6. A member replays an administrator’s client state against a view guarded by f:viewAction
Configuration Mojarra 4.1.17 MyFaces 4.1.4

Server state saving (the default)

500, ViewExpiredException; the state belongs to the administrator’s session

500, ViewExpiredException

Client state, f:viewAction without onPostback

200, the action ran: the counter became 1

200, the action ran

Client state, onPostback="true"

403 from the guard; the administrator’s own postback still ran (200)

403

The example proves a narrower point than it seems. A guard on the initial request is not a guard on every request; a URL that must be restricted is restricted in web.xml (next section), where the container checks every request to the URL, GET or POST, with any view state. Use f:viewAction for the decisions only the application can make (the book exists, the loan belongs to this caller), with onPostback="true" when the answer can change between requests, and return the right status: the guard above calls responseSendError(403, ...) and ends the lifecycle with responseComplete().

Securing URLs in web.xml

security-constraint is enforced by the container before any filter or servlet runs. In the scratch runs a request that failed the constraint was answered by the container itself: a redirect to the login page for an anonymous caller, 403 for an authenticated caller without the role. Neither response carried the headers of the application’s own filter (see Response Headers), which is the observable proof that the check comes first.

web.xml (excerpt) — URL constraints, uncovered methods and roles
<security-constraint>
    <web-resource-collection>
        <web-resource-name>Administration</web-resource-name>
        <url-pattern>/admin/*</url-pattern>
    </web-resource-collection>
    <auth-constraint><role-name>ADMIN</role-name></auth-constraint>
</security-constraint>
<security-constraint>
    <web-resource-collection>
        <web-resource-name>Members</web-resource-name>
        <url-pattern>/members/*</url-pattern>
    </web-resource-collection>
    <auth-constraint><role-name>**</role-name></auth-constraint>
</security-constraint>
<deny-uncovered-http-methods/>
<security-role><role-name>ADMIN</role-name></security-role>
<security-role><role-name>MEMBER</role-name></security-role>
  • <role-name>**</role-name> means "any authenticated caller, whatever the roles" (Servlet 3.1 and later); * means any role declared with security-role. The member and the administrator both reached /members/*.

  • <deny-uncovered-http-methods/> (Servlet 3.1 and later) answers 403 to every method the constraints do not mention. See the second table below for what happens without it.

  • A transport-guarantee of CONFIDENTIAL makes the container refuse plain HTTP for the URLs: the scratch run answered 302 to the https:// URL. Add it to every constraint (or to a constraint on /*), together with <secure>true</secure> on the session cookie (Cookie Attributes and SameSite).

  • The groups returned by the identity store are the role names; security-role declares them for tools and for the * pattern.

Faces-specific ways around a constraint were tested by requesting the protected view as an anonymous caller. A status of 302 is the redirect to the login page (access refused); 200 means the page was served.

Table 7. Anonymous GET requests to the administration view under three configurations (Tomcat 11.0.26, Mojarra 4.1.17; jakarta.faces.AUTOMATIC_EXTENSIONLESS_MAPPING was on in every run)
Request Constraint /admin/* (the example) Constraint on the exact /admin/panel.xhtml only Constraint /admin/*, plus an additional /faces/* mapping of the servlet

/admin/panel.xhtml

302

302

302

/admin/panel (extensionless)

302

200

302

/faces/admin/panel.xhtml

404

404

200

/admin//panel.xhtml, /admin/./panel.xhtml, /admin/panel.xhtml;x=1, /admin/%2e/panel.xhtml

302

302

302

/admin/Panel.xhtml, /admin/panel.xhtml/

302

404

302

  • Constrain a directory, not a file name. /admin/* covered the .xhtml name, the extensionless name and every path variation tried. A constraint written for /admin/panel.xhtml left /admin/panel open once jakarta.faces.AUTOMATIC_EXTENSIONLESS_MAPPING (Faces 4.0) was on.

  • Every mapping of the FacesServlet is a second door. With /faces/* mapped as well, /faces/admin/panel.xhtml bypassed a constraint on /admin/*. Constrain /* and open what must be public, or add a constraint for each prefix; never map a prefix you do not secure.

  • Resources are not views. Everything under resources/ is also a static file (see resource handling); do not keep anything private there.

Table 8. Anonymous POST and PUT to /admin/panel.xhtml when the constraint lists only GET
Configuration GET POST PUT

<http-method>GET</http-method> in the constraint, no deny-uncovered-http-methods

302

200

200

The same constraint with <deny-uncovered-http-methods/>

302

403

403

No http-method element (the example)

302

302

302

A constraint that names methods protects only those methods. Omit the http-method elements to cover all of them, and add deny-uncovered-http-methods.

Redirects after login

A redirect target that arrives in a request parameter (?next=...) is an open redirect: the login page becomes a trampoline to any site, and the user’s trust in your domain is used for phishing. The mechanisms that keep the original URL do it on the server (login-to-continue), which is why LoginBean above never reads a redirect parameter. When an application must accept one, accept only a path inside itself:

SafeRedirect.java
package org.example.bookshelf.web;

import java.net.URI;
import java.net.URISyntaxException;

/** Accepts only paths inside this application: no scheme, no host, no protocol-relative "//", no backslash tricks. */
public final class SafeRedirect {

    private SafeRedirect() {
    }

    public static String localPathOrDefault(String candidate, String contextPath, String fallback) {
        if (candidate == null || candidate.isBlank() || candidate.contains("\\") || candidate.chars().anyMatch(Character::isISOControl)) {
            return fallback;
        }
        try {
            URI uri = new URI(candidate).normalize();
            String path = uri.getRawPath() == null ? "" : uri.getRawPath();
            boolean local = uri.getScheme() == null && uri.getRawAuthority() == null
                    && path.startsWith("/") && !path.startsWith("//");
            boolean inApp = local && (path.equals(contextPath) || path.startsWith(contextPath + "/"));
            return inApp ? uri.toString() : fallback;
        } catch (URISyntaxException e) {
            return fallback;
        }
    }
}

The method was run against fourteen candidates: the application’s own absolute path stayed, and https://evil.example/, //evil.example/x, /\evil.example, javascript:alert(1), /other/app, /bookshelfevil/x, a path with a space, a leading tab, an empty value and null fell back to the landing page. /bookshelf/../admin is normalised first and refused (it leaves the context). Reject, do not "fix", what you do not understand. A redirect also never carries a session id or a token in its query string.

Cross-Site Request Forgery

A forged request is one the victim’s browser sends, with the victim’s cookies, because a page on another site told it to. Authentication by cookie cannot tell the difference: the request carries a valid session id. Faces has a built-in advantage: every postback has to present a jakarta.faces.ViewState, and an attacker who cannot read the victim’s page does not know the victim’s value. That protection has conditions.

Table 9. Does the view state protect a state-changing request? Observed forged posts: the attacker’s own view state, the victim’s session cookie, and Origin: https://evil.example (the same-origin filter of the next subsection was not mapped in these runs)
Configuration Mojarra 4.1.17 MyFaces 4.1.4

Server state saving (the default)

500 ViewExpiredException; nothing changed

500 ViewExpiredException; nothing changed

Client state saving (jakarta.faces.STATE_SAVING_METHOD client), state encrypted and authenticated

200, the action ran as the victim (counter 0 to 1)

200, the action ran as the victim

Stateless view (f:view transient="true")

200: no cookie, no earlier request, the constant value stateless was enough

200, same

Server-side state is a per-session secret: the view state value is a reference into the victim’s own session, so a value taken from the attacker’s session means nothing there. Client state is self-contained and, as the multi-window page and the state saving page also note, not bound to the session or the user: the attacker loads the form in their own browser, copies the field, and any victim’s session accepts it. Encryption and the MAC make the state tamper-evident, which is a different property from being unguessable by an attacker who simply asks for one. A stateless view’s state is the constant word stateless. For the state saving options themselves see state saving and stateless views; what to do about forged requests when the view state cannot be the defence is below.

The server state is the default; keep it for any view that changes anything. It is a defence only when the state is a random reference: Mojarra’s com.sun.faces.generateUniqueServerStateIds (default true) and MyFaces' random view-state tokens (org.apache.myfaces.RANDOM_KEY_IN_VIEW_STATE_SESSION_TOKEN) are what make it unguessable; do not switch them off for convenience.

Protected Views

The view state protects postbacks. A navigation by GET, a link or a bookmark, has none. Faces 2.2 added protected views for the pages that would be harmful to open from outside: a protected view may be reached only from another Faces view of the same application.

faces-config.xml — declare the protected views
<?xml version="1.0" encoding="UTF-8"?>
<faces-config 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-facesconfig_4_1.xsd"
              version="4.1">
    <protected-views>
        <url-pattern>/admin/confirm-delete.xhtml</url-pattern>
    </protected-views>
</faces-config>
admin/confirm-delete.xhtml — the protected view is a read-only confirmation; the change is a POST
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html">
<h:head><title>Confirm</title></h:head>
<h:body>
    <p id="who">confirm-delete user=#{callerBean.name} deletes=#{callerBean.deletes}</p>
    <h:form id="f">
        <h:commandButton id="confirm" value="Delete every withdrawn title" action="#{adminBean.delete}"/>
    </h:form>
    <h:messages/>
</h:body>
</html>

How it works, per the specification (Restore View, "View Protection") and as observed on Mojarra and MyFaces:

  • <protected-views> holds url-pattern elements. ViewHandler.addProtectedView, removeProtectedView and getProtectedViewsUnmodifiable read and change the same set at runtime.

  • Links and redirects that the Faces components generate to a protected view get a token: h:link in index.xhtml rendered /bookshelf/admin/confirm-delete.xhtml?jakarta.faces.Token=... (the parameter name is ResponseStateManager.NON_POSTBACK_VIEW_TOKEN_PARAM, the value comes from getCryptographicallyStrongTokenFromSession). The token is per session: two page loads of one session carried the same value, another session’s value was refused (500) on both implementations.

  • A non-postback request for a protected view must carry the token, and when a Referer or Origin header is present it must name a view of the application.

Table 10. Requests for the protected view (administrator’s session)
Request Mojarra MyFaces

GET without a token (as from a typed address or another site)

500 ProtectedViewException

500 ProtectedViewException

GET with the token from the link

200

200

GET with a wrong token

500

500

GET with the token and Referer: https://evil.example/page

500, JSF1099: Referer [sic] header value ... does not appear to be a protected view

500, no message

GET with the token and Origin: https://evil.example

500, JSF1100: Origin [sic] header value ... does not appear to be a protected view

500, no message

GET with the token and Referer pointing at a page of the application

200

200

POST of the confirmation button with the view state

200, the action ran

200, the action ran

Three consequences:

  • The token is unknown to a third-party page (it is per session and is only in links the application renders), so a forged link, an image tag or a redirect from another site cannot open the protected view. It is protection for GET navigation. It does not replace the view state on the postback, and a protected view whose own page changes state on load (a f:viewAction that deletes) is still a bad design: a link prefetcher or a robot with the token would fire it. The confirmation page above only shows the question; the deletion is a POST that carries the view state.

  • The failure is an exception and an HTTP 500. ProtectedViewException is not a ViewExpiredException; map it to your own 403 page with an <error-page> for jakarta.faces.application.ProtectedViewException (observed on Mojarra: the mapped static page was served and the status stayed 500) or handle it in a custom ExceptionHandler that sets the status (see error pages and the production hardening checklist). Do not show the stack trace.

  • Protected views are not authorisation. The jakarta.faces.Token proves the request started from your application, not that the caller may open the view; the constraint on /admin/* still decides that.

Protecting What the View State Does Not Cover

Client state, stateless views and every non-Faces endpoint (a REST resource, a servlet, an upload endpoint, a WebSocket handshake) have no view state. Three measures cover them, in this order of importance.

1. SameSite cookies. A session cookie with SameSite=Lax is not sent by a browser on a cross-site POST, which blocks the classic forged form (Cookie Attributes and SameSite). It is defence in depth: browsers differ in age and policy, and Lax still sends the cookie on top-level GET navigations, which is why a GET must never change state.

2. A same-origin check in a filter. Browsers send Origin on cross-origin POSTs and, in current browsers, Sec-Fetch-Site on every request. A filter that refuses unsafe methods when the header says the request came from another site:

SameOriginFilter.java
package org.example.bookshelf.web;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Set;

/** Defence in depth: refuse state-changing requests that the browser says come from another origin. */
public class SameOriginFilter implements Filter {

    private static final Set<String> SAFE = Set.of("GET", "HEAD", "OPTIONS");

    private String publicOrigin;

    @Override
    public void init(FilterConfig config) throws ServletException {
        // Configured, never taken from the request: for example https://books.example.org
        publicOrigin = System.getenv("BOOKSHELF_PUBLIC_ORIGIN");
        if (publicOrigin == null || publicOrigin.isBlank()) {
            throw new ServletException("BOOKSHELF_PUBLIC_ORIGIN is not set");
        }
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        if (!SAFE.contains(request.getMethod())) {
            String origin = request.getHeader("Origin");
            boolean crossSite = "cross-site".equals(request.getHeader("Sec-Fetch-Site"))
                    || (origin != null && !origin.equals(publicOrigin));
            if (crossSite) {
                ((HttpServletResponse) res).sendError(HttpServletResponse.SC_FORBIDDEN, "Cross-origin request refused");
                return;
            }
        }
        chain.doFilter(req, res);
    }
}

Observed with the filter mapped to /* on the scratch app (the origin comes from the environment variable BOOKSHELF_PUBLIC_ORIGIN, never from the request):

Table 11. POST /index.xhtml with different headers
Header Status

none (old browsers, scripts, curl)

200

Origin: https://evil.example

403

Origin equal to the configured origin

200

Sec-Fetch-Site: cross-site

403

Sec-Fetch-Site: same-origin

200

A request with neither header passes, so the filter does not replace the view state or the cookie flags; it removes the most common attack. The filter’s configured origin is deliberately an environment variable that must be set: the filter refuses to start without it, which is safer than silently doing nothing.

3. A token of your own. For a service that is called by scripts on your pages, send a random per-session token in a custom request header and compare it on the server; a cross-site page cannot add custom headers to a form post. Faces needs none of this for its own postbacks, where the view state is the token (server state).

Related: CORS decides which sites may read responses; it is not a CSRF defence, because a forged form post is not blocked by it. The same ideas in another stack are in the Laravel CSRF page.

Cross-Site Scripting and the escape Attribute

Cross-site scripting happens when data that an attacker controls (a review, a book title, a query parameter, a name imported from a feed) reaches the browser as markup or script. Faces helps in the most common place: the components that write text escape it. The protection is by context, and the contexts a template writes into are different.

Table 12. How hostile data comes out, by output context (the same PayloadBean strings on Mojarra 4.1.17 and MyFaces 4.1.4, run in headless Chrome)
Context Output Safe?

h:outputText value="#{...}" and #{...} in template text

<, > and & escaped on both; " escaped to &quot; on MyFaces and left as it is on Mojarra, which is harmless in element content

Yes

h:outputText ... escape="false", h:outputFormat ... escape="false", f:selectItem ... itemEscaped="false"

raw: <script>alert(1)</script> was written into the page and ran (three dialogs in the test page)

No. The data is markup

h:messages and h:message (FacesMessage summary and detail)

always escaped (&lt;b&gt;detail&lt;/b&gt;); the components have no escape attribute

Yes

An HTML attribute: data-title="#{...}"

", &, <, > escaped on both; reading dataset.title returned the exact original string

Yes, for the attribute

A script string: <script>var js = '#{...}';</script>

HTML-escaped but not JavaScript-escaped: with the value ';window.__pwned=...;// the script ran on both implementations

No

An event-handler attribute: onclick="alert('#{...}')"

The ' is not escaped; the value ');window.__pwned=...;// ran on click on both

No

A URL attribute: h:outputLink value="#{...}"

javascript:alert(1) was written as the href on both

No, unless the scheme is checked

The rule behind the table: the escaping Faces applies is HTML escaping. A browser decodes entities in attribute values before it hands the text to the JavaScript engine, so HTML-escaped text inside a script string or an event handler is still an injection, and no amount of HTML escaping fixes a URL scheme. The correct forms:

xss-safe.xhtml — text escaped, data for scripts in an attribute, markup only after sanitising
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<h:head>
    <title>Hostile data, safe output</title>
    <h:outputScript name="title.js" library="bookshelf" target="head"/>
</h:head>
<h:body>
    <!-- 1. text: escaped by default, in a component and in plain template text -->
    <p id="t1"><h:outputText value="#{payloadBean.title}"/></p>
    <p id="t3">#{payloadBean.title}</p>

    <!-- 2. data for a script: an attribute (escaped for HTML), read with dataset by a script file -->
    <button id="b2" type="button" data-title="#{payloadBean.quoteBreak}">Show the title</button>
    <p id="shown"></p>

    <!-- 3. the rare field that must hold markup: sanitised, then written unescaped -->
    <p id="t5"><h:outputText value="#{payloadBean.htmlReview}" escape="false" converter="bookshelf.safeHtml"/></p>
</h:body>
</html>
resources/bookshelf/title.js — the script is a file; it reads the data, never builds code from it
document.addEventListener('DOMContentLoaded', function () {
    document.querySelectorAll('[data-title]').forEach(function (button) {
        button.addEventListener('click', function () {
            // textContent never parses markup, whatever the title contains
            document.getElementById('shown').textContent = button.dataset.title;
        });
    });
});

In the headless run of xss-safe.xhtml (both implementations) the click on the button wrote ');window.__pwned=(window.__pwned||0)+1;// into the paragraph as text and window.__pwned stayed undefined.

  • Keep escape="true" (the default). escape exists on h:outputText, h:outputFormat, h:outputLabel and, as itemEscaped / itemLabelEscaped, on f:selectItem and f:selectItems (all default true in the VDL). Component libraries add their own: p:message, p:messages and p:growl escape by default and have an escape attribute (PrimeFaces 16 documentation); check it on every component that renders user text.

  • What the component-library runs found. The same rules were tested on the library components and the results are on their own pages: a p:messages (and p:growl) message with escape="false" ran script, while the default showed the same text inert (PrimeFaces messages); p:menuitem escape="false" ran script, and the other labels and headers of the panel, menu and dialog family were escaped (PrimeFaces panels, menus and dialogs); p:chart writes its JSON into an inline script without escaping, so a label containing a closing script tag injected markup (PrimeFaces charts); the p:timeline event data of PrimeFaces Extensions is shown as HTML inside an inline script (PrimeFaces Extensions); and b:growl of BootsFaces does not escape unless escape="true" is set, while b:messages does (BootsFaces). Data that reaches a script context needs script escaping whichever library draws it.

  • escape="false" is for text you wrote. A resource bundle you control may hold <b>; the same bundle read with h:outputFormat and parameters that contain user data is an injection (the parameters are written raw too). Put user data in a separate, escaped component.

  • When a field must hold markup (a review with bold and links), sanitise with a library that parses HTML and applies an allow-list. The listing uses the OWASP Java HTML Sanitizer (com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260924.2, the newest release on Maven Central on 2026-10-08) inside a managed converter; for the sample input <b>great</b> <img src=x onerror=...> <a href="javascript:...">x</a> <a href="https://example.org/">ok</a><script>...</script> the output kept <b> and the https link (with rel="nofollow") and dropped the img, the javascript: link and the script. Sanitise on output or at least be able to re-run it: a policy tightened next year must apply to data stored today.

    HtmlSanitizerConverter.java
    package org.example.bookshelf.web;
    
    import jakarta.enterprise.context.Dependent;
    import jakarta.faces.component.UIComponent;
    import jakarta.faces.context.FacesContext;
    import jakarta.faces.convert.Converter;
    import jakarta.faces.convert.FacesConverter;
    import org.owasp.html.HtmlPolicyBuilder;
    import org.owasp.html.PolicyFactory;
    
    /** Sanitises on the way out. Only for the rare field that must hold HTML; everything else keeps escape="true". */
    @Dependent
    @FacesConverter(value = "bookshelf.safeHtml", managed = true)
    public class HtmlSanitizerConverter implements Converter<String> {
    
        private static final PolicyFactory POLICY = new HtmlPolicyBuilder()
                .allowElements("b", "i", "em", "strong", "p", "ul", "ol", "li", "a")
                .allowUrlProtocols("https")
                .allowAttributes("href").onElements("a")
                .requireRelNofollowOnLinks()
                .toFactory();
    
        @Override
        public String getAsObject(FacesContext context, UIComponent component, String value) {
            return value;
        }
    
        @Override
        public String getAsString(FacesContext context, UIComponent component, String value) {
            return value == null ? "" : POLICY.sanitize(value);
        }
    }
  • Never build JavaScript from data. Pass data in data-* attributes (as above), in the response of an Ajax call parsed as JSON, or as JSON produced by a JSON library, never by string concatenation. Pushed text goes into the DOM with textContent, never innerHTML (see the push security notes).

  • Check URL schemes. Mojarra can refuse them globally: with com.sun.faces.enableScriptsInAttributeValues set to false the h:outputLink above was rendered with href="" instead of the javascript: URL. The parameter is Mojarra-only (MyFaces was not tested for an equivalent); the portable fix is to validate or build the URL on the server, accepting only https:, http: and relative paths.

  • Faces validates what a select can submit. A request that posts a value that is not among the items of an h:selectOneMenu is answered with Validation Error: Value is not valid and the model is not touched (both implementations). Hidden fields, f:param, request parameters and f:viewParam have no such list: validate them (required, validators, Bean Validation), as the validation pages of this section describe, and remember that client-side validation is a convenience, not a control.

  • HTML in the page is not the only sink. CSV, PDF and file names are others (the file download page covers the CSV formula injection and the header encoding; the PrimeFaces exporters write formula characters verbatim, see PrimeFaces data components).

Content Security Policy

A Content Security Policy is a response header with which the application tells the browser where scripts (and styles, images, frames) may come from. A good policy turns most XSS from "attacker script runs" into "the browser refuses it and, if asked, reports it". Faces itself writes inline script in many places (command links, Ajax handlers, faces.js initialisation), so a strict policy needs the framework’s help: each inline script must carry a nonce that the header also names. Four situations today, observed in headless Chrome or, for MyFaces, by searching the implementation:

Table 13. CSP support, observed
Setup Header and rendering Behaviour

Mojarra 4.1.17 with com.sun.faces.enableCspNonce set to true (the parameter exists since Mojarra 4.0.16)

Content-Security-Policy: script-src 'self' 'nonce-...' 'strict-dynamic' (the default of com.sun.faces.cspPolicy); every script Faces renders has the nonce attribute, including faces.js and the one from h:outputScript; the onclick handlers of command components became nonce-stamped scripts that register listeners (mojarra.ael)

A command button with f:ajax updated the page; a command link posted; the page’s own <script> without a nonce was blocked

The standard parameter jakarta.faces.ENABLE_CSP_NONCE on Mojarra 4.1.17

no Content-Security-Policy header at all

The parameter is Faces 5.0 (below); 4.1 ignores it

MyFaces 4.1.4

no CSP header: a search of the MyFaces 4.1.4 implementation jar found no Content-Security-Policy header and no CSP parameter

No built-in support; send the header from a filter and use PrimeFaces or your own scripts as below

PrimeFaces 16.0.0 with primefaces.CSP set to true (either implementation)

Content-Security-Policy: script-src 'self' 'nonce-...' followed by a semicolon; PrimeFaces stamps the nonce on its scripts and registers inline handlers through PrimeFaces.csp.register

p:commandButton worked on both. h:commandButton with f:ajax worked on MyFaces and did not update on Mojarra (the click handler ran, the Ajax update did not), which is the limitation the PrimeFaces documentation lists

web.xml (excerpts) — Mojarra’s nonce, PrimeFaces' CSP, and PrimeFaces in report-only mode
<!-- Mojarra 4.0.16 and later (including 4.1): nonce and header from the implementation -->
<context-param>
    <param-name>com.sun.faces.enableCspNonce</param-name>
    <param-value>true</param-value>
</context-param>
<!-- optional: a different policy; #{nonce} stands for the nonce of the response -->
<context-param>
    <param-name>com.sun.faces.cspPolicy</param-name>
    <param-value>script-src 'self' 'nonce-#{nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'</param-value>
</context-param>

<!-- PrimeFaces: on, or reportOnly to find the violations first -->
<context-param>
    <param-name>primefaces.CSP</param-name>
    <param-value>reportOnly</param-value>
</context-param>
<context-param>
    <param-name>primefaces.CSP_REPORT_ONLY_POLICY</param-name>
    <param-value>script-src 'self'; report-uri /csp-reports</param-value>
</context-param>

com.sun.faces.enableCspNonce with the default policy and primefaces.CSP set to true were run. The custom com.sun.faces.cspPolicy value is an illustration (the parameter, its default and the #{nonce} placeholder are documented), and reportOnly with its policy parameter and the report-uri endpoint are PrimeFaces' documented settings; none of the three was run. Do not enable both the Mojarra nonce and primefaces.CSP for the same application: each sends its own header. The PrimeFaces parameters (primefaces.CSP, CSP_POLICY, CSP_REPORT_ONLY_POLICY) are listed with the other PrimeFaces settings in the PrimeFaces setup page.

csp.xhtml — what the policy lets through and what it does not
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html" xmlns:f="jakarta.faces.core">
<h:head><title>CSP</title>
    <h:outputScript name="app.js" library="bookshelf"/>
    <!-- an inline script without a nonce: the policy blocks it -->
    <script>window.__inline = 'ran';</script>
</h:head>
<h:body>
    <h:form id="f">
        <h:outputText id="out" value="count=#{cspBean.count}"/>
        <h:commandButton id="hello" value="hello" action="#{cspBean.inc}" onclick="window.__onclick=1;"><f:ajax render="out"/></h:commandButton>
        <h:commandLink id="lnk" value="link" action="#{cspBean.inc}" onclick="window.__linkclick=1;"/>
    </h:form>
</h:body>
</html>

What the headless run of that page showed under the Mojarra policy: window.__loaded was 1 (the file from h:outputScript ran), window.__inline was undefined (the inline script was refused), the onclick of the Ajax button ran and the page updated (count=1), and the command link posted. Moving the inline script into a resource file, as h:outputScript does, is what a CSP migration is mostly about: inline scripts to files, inline handlers to listeners, javascript: URLs to buttons, eval and new Function out of your own code, and every third-party script to a host named in script-src.

Faces 5.0 (upcoming). The nonce is moving into the specification. The Jakarta Faces 5.0 API defines ResourceHandler.ENABLE_CSP_NONCE_PARAM_NAME (the context parameter jakarta.faces.ENABLE_CSP_NONCE), ResourceHandler.CSP_POLICY_PARAM_NAME (jakarta.faces.CSP_POLICY, which must contain #{nonce}), ResourceHandler.DEFAULT_CSP_POLICY (the same default as Mojarra’s) and ResourceHandler.getCurrentNonce(FacesContext), so an application can stamp the nonce on its own scripts. Verified in the jakarta.faces-api:5.0.0-M9 class files and in the Faces repository (issue 1590, "CSP support", milestone 5.0). It is a milestone: names may still change, 4.1 implementations ignore it, and nothing in this section depends on it. Until a 5.0 implementation is your platform, use the vendor parameter (Mojarra) or PrimeFaces' CSP, and send the rest of your policy from a filter (Response Headers).

Cookie Attributes and SameSite

Three attributes decide how much damage a stolen or forged cookie can do: HttpOnly (scripts cannot read it, so an XSS cannot send the session id away), Secure (it travels only over HTTPS) and SameSite (Strict never sends it on a cross-site request, Lax sends it on top-level GET navigations only, None always, and requires Secure). MDN notes that some browsers use Lax when the attribute is missing, and that this default is a more permissive Lax: the cookie is still sent on a cross-site POST made within two minutes of the cookie being set. An explicit SameSite=Lax does not have that exception, so set the attribute instead of relying on the default.

The session cookie. Servlet 6.0 (Jakarta EE 10) added generic cookie attributes, so SameSite can be set in web.xml as an attribute element of cookie-config (and in code with SessionCookieConfig.setAttribute):

web.xml (excerpt) — the session cookie
<session-config>
    <cookie-config>
        <http-only>true</http-only>
        <attribute>
            <attribute-name>SameSite</attribute-name>
            <attribute-value>Lax</attribute-value>
        </attribute>
    </cookie-config>
    <tracking-mode>COOKIE</tracking-mode>
</session-config>

Observed Set-Cookie of the session on both implementations: JSESSIONID=...; Path=/bookshelf; HttpOnly; SameSite=Lax; with <secure>true</secure> added, ...; Secure; HttpOnly; SameSite=Lax even though the request came over plain HTTP, because the element says "all session tracking cookies must be marked secure independent of the nature of the request". In production add <secure>true</secure> and a CONFIDENTIAL transport guarantee (Securing URLs in web.xml); the scratch runs used HTTP, so the effect of Secure on a browser was not exercised. Prefer SameSite=Lax for an application that users reach from links on other sites, and Strict for a back-office application that nobody links to. <tracking-mode>COOKIE</tracking-mode> keeps the session id out of URLs: the resource handling page records that without it the first response carries ;jsessionid= in generated URLs, which end up in logs, referrers and bookmarks. (Tomcat can also add SameSite to every cookie it writes with the sameSiteCookies attribute of its cookie processor; other servers have their own setting.)

Cookies your application sets. Faces 4.0 extended ExternalContext.addResponseCookie(name, value, properties) (issue 1570 in the specification’s change list): the keys comment, domain, maxAge, secure, path and httpOnly are the known ones, and any other key becomes a cookie attribute through Cookie.setAttribute, which is how SameSite is set:

ThemeCookieBean.java — a preference cookie with explicit attributes
package org.example.bookshelf.web;

import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.util.Map;

@Named
@RequestScoped
public class ThemeCookieBean {

    @Inject
    FacesContext facesContext;

    public String dark() {
        facesContext.getExternalContext().addResponseCookie("bookshelf.theme", "dark", Map.of(
                "path", "/bookshelf",
                "maxAge", 31_536_000,
                "secure", true,
                "httpOnly", true,
                "SameSite", "Strict"));
        return null;
    }
}

Observed on both implementations: bookshelf.theme=dark; Expires=...; Max-Age=31536000; Path=/bookshelf; Secure; HttpOnly; SameSite=Strict. The map values need the right types (Integer for maxAge, Boolean for secure and httpOnly). Never put a role, a price or an identity in a cookie the user can edit; keep that on the server in the session.

Cookies the implementations write. MyFaces wrote oam.Flash.RENDERMAP.TOKEN=...; Path=/bookshelf; HttpOnly; SameSite=Lax for its flash scope; its deleting cookie oam.Flash.REDIRECT=true; Expires=Thu, 01 Jan 1970 00:00:10 GMT carried no flags (it holds no secret). The flash cookie of Mojarra was not seen in these runs (it is written only when the flash holds data). Audit the cookies of your deployment with the browser’s developer tools once; the list differs by implementation and by component library.

Session Fixation and Logout

In a session fixation attack the attacker obtains a valid session id (by visiting the site) and makes the victim use it (a crafted link with ;jsessionid=, a cookie set from a sibling domain); after the victim logs in, the attacker’s copy of the id belongs to an authenticated session. The remedy is the same everywhere: change the session id when the privilege level changes.

The scratch test: an attacker’s anonymous session C28F29ED... was given to the victim’s browser, the victim logged in through the form, and the attacker then used the old id:

Table 14. Session id around a login (both implementations, container authentication through the mechanism)
Step Result

Victim logs in carrying the attacker’s id

Set-Cookie: JSESSIONID=... with a new id

Attacker requests /index.xhtml with the old id

anonymous (user=(anonymous))

Attacker requests /members/shelf.xhtml with the old id

302 to the login page

The container did it: Tomcat issued a new id when the mechanism completed the authentication. It is container behaviour, not a Faces feature; check it on your server by repeating the test. When the application authenticates by itself (a custom filter, a token exchange), rotate the id explicitly with the Servlet API:

SessionHygieneBean.java — changeSessionId() keeps the session attributes, issues a new id
package org.example.bookshelf.web;

import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.servlet.http.HttpServletRequest;

@Named
@RequestScoped
public class SessionHygieneBean {

    @Inject
    FacesContext facesContext;

    /** For code that authenticates by itself (not through the container): always rotate the id right after a successful login. */
    public String rotate() {
        HttpServletRequest request = (HttpServletRequest) facesContext.getExternalContext().getRequest();
        request.getSession().setAttribute("marker", "kept");
        String before = request.getSession().getId();
        String after = request.changeSessionId();
        facesContext.addMessage(null, new FacesMessage("rotated=" + !before.equals(after) + " marker=" + request.getSession().getAttribute("marker")));
        return null;
    }
}

Observed: rotated=true marker=kept (the attribute set before the call survived), the cookie changed, and the caller stayed logged in.

Logout ends the authenticated state and the session: HttpServletRequest.logout() clears the container’s authentication data and ExternalContext.invalidateSession() destroys the session and everything in it, including view-scoped beans and client-window beans. The LoginBean.logout() above is bound to a commandButton (a POST with the view state), never a link: a logout link is a GET that any page can trigger, and the rule is that GET does not change state. After logging out through the button, the old cookie was answered with the redirect to the login page on both implementations. With OpenID Connect, also end the provider’s session (logout.notifyProvider, OpenID Connect), otherwise the next login is silent.

Idle and absolute session timeouts belong in web.xml (session-config/session-timeout, in minutes) and in the identity provider; keep them short for administrative areas.

Response Headers

Some protections do not belong in a component: framing, sniffing, the referrer and caching of private pages. A servlet filter sets them for every response the application produces:

SecurityHeadersFilter.java and its web.xml mapping
package org.example.bookshelf.web;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

/** Adds the response headers that do not depend on the page: sniffing, referrer, framing and caching of private areas. */
public class SecurityHeadersFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;
        response.setHeader("X-Content-Type-Options", "nosniff");
        response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
        response.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
        String path = request.getServletPath();
        if (path.startsWith("/members/") || path.startsWith("/admin/")) {
            response.setHeader("Cache-Control", "no-store");
        }
        chain.doFilter(req, res);
    }
}
<filter>
    <filter-name>securityHeaders</filter-name>
    <filter-class>org.example.bookshelf.web.SecurityHeadersFilter</filter-class>
</filter>
<filter>
    <filter-name>sameOrigin</filter-name>
    <filter-class>org.example.bookshelf.web.SameOriginFilter</filter-class>
</filter>
<filter-mapping>
    <filter-name>securityHeaders</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

Observed on both implementations for a member’s request to /members/shelf.xhtml: Cache-Control: no-store, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin and Content-Security-Policy: frame-ancestors 'none' (which forbids framing, the modern replacement for X-Frame-Options, and stops clickjacking). Faces and Tomcat did not overwrite them. Notes:

  • The container answers first. The redirect to the login page and the 403 were produced before the filter ran and carried none of these headers (Cache-Control: private was Tomcat’s own). The login page itself, an ordinary Faces view, had all three. Put what must be on every response (HSTS, for one: Strict-Transport-Security, sent only over HTTPS and decided at the proxy or server) where the container’s responses also pass.

  • Authenticated pages get no-store. Without it a browser may show a cached private page from the back button after logout. Faces sets no caching header for views in these runs.

  • Do not mix the filter’s Content-Security-Policy with the nonce policy of Mojarra or PrimeFaces unless you want both enforced (a browser enforces every policy it receives): send the whole policy from one place.

Other Surfaces

  • Error pages. With no <error-page>, the 500 response of Tomcat 11 contained the exception class, the Faces stack trace (com.sun.faces.lifecycle...) and the server version (observed with Mojarra). Map java.lang.Throwable and the status codes to a static page; the mapping served the page and kept the status (observed on Mojarra). Handle ViewExpiredException and ProtectedViewException specifically, and on Tomcat also switch off the report of the ErrorReportValve: exception handling, production hardening checklist.

    <error-page>
        <exception-type>java.lang.Throwable</exception-type>
        <location>/error/server-error.html</location>
    </error-page>
    <error-page>
        <error-code>404</error-code>
        <location>/error/server-error.html</location>
    </error-page>
  • Project stage. Run with jakarta.faces.PROJECT_STAGE Production (the default). The Development stage prints stack traces, the component tree and request parameters in its error pages (see the development pages); how the stage is resolved and what else it changes is on the configuration page.

  • Client state keys. Encrypted and authenticated client state needs a fixed key supplied by the server, not by the WAR: see client state keys. Never turn off com.sun.faces.disableClientStateEncryption or org.apache.myfaces.USE_ENCRYPTION to look at the state; the encrypted, authenticated value is what keeps a forged serialised object out of the server.

  • Push. f:websocket has its own checklist (protect /jakarta.faces.push/<channel>, never trust a user from a request parameter): WebSocket push security.

  • Window ids. The client window id comes from the browser: multi-window security notes.

  • Uploads and downloads. Validate the type and size, store outside the web root, and escape CSV cells: file upload and download.

  • Resources and contracts. Everything under resources/ can be requested as a file (resource handling); contract directories, the con parameter and contract names built from request input have their own warnings in resource library contracts.

  • Sensitive data in the view. A view-scoped bean or a component attribute lives in the view state, in the session or, with client state saving, in the browser. Keep tokens, passwords and personal data out of it.

Secrets and Configuration

Every secret on this page came from outside the WAR, and that is the rule: a secret in web.xml, faces-config.xml, a managed-bean field, an annotation or a properties file in the repository is a secret everybody with the code has, forever.

Table 15. Where each secret lives
Secret Supplied by

Account passwords

Never stored; PBKDF2 hashes in the database (PasswordService)

Seed passwords of a demo

Environment variables of the process (BOOKSHELF_SEED_ADMIN_PASSWORD and BOOKSHELF_SEED_MEMBER_PASSWORD), only in development; the scratch runs generated random values per run

OpenID Connect client id and secret

Environment variables read by OpenIdConfig through an expression in the annotation

Database and LDAP credentials

The server’s data source and naming configuration; expressions in the annotation

Client state keys

JNDI entry faces/ClientSideSecretKey (Mojarra) or the org.apache.myfaces.SECRET and MAC_SECRET parameters (MyFaces), from the server’s configuration (state saving page)

Public origin of the application

BOOKSHELF_PUBLIC_ORIGIN, refused to start without it

The same discipline applies to examples in documentation and tests: placeholders such as BASE64-KEY-FROM-YOUR-SECRET-STORE, generated test values, and a secret scanner such as detect-secrets in the pipeline.

Spring Security Through JoinFaces

Faces on Spring Boot normally uses Spring Security rather than Jakarta Security: the container is Spring Boot’s embedded server and the security layer is Spring’s. JoinFaces integrates the two. Its reference describes "Spring Security JSF Facelet Tag support": when Spring Security is on the classpath JoinFaces registers a tag library (namespace joinfaces.org/security) with the tags authorize, anonymous, authenticated and fullyAuthenticated, and a set of EL functions (areAllGranted, areAnyGranted, areNotGranted, isAllowed, and others) that stand in for rendered="#{request.isUserInRole(...)}" in the views. URL rules, login, CSRF protection and password encoding are then Spring Security’s: see Spring Security for the filter chain and server-side web UI frameworks for JoinFaces next to Thymeleaf and Vaadin. Two Faces-specific points still apply. Spring Security’s request-forgery protection and the Faces view state are separate mechanisms, so decide explicitly which one protects each state-changing request and test a forged post (this page did not run JoinFaces, but the Spring Boot with JoinFaces page did: a Faces post, plain or through f:ajax, that carried the _csrf hidden field was accepted and one without it got 403, so both the Spring token and the view state are sent; and a view reached through the /faces/* mapping alias bypassed a /admin/** rule until the mapping was restricted). And the escaping, CSP and cookie guidance above is unchanged, because it is about how Faces renders. The Spring Boot with JoinFaces page covers the starters and properties. Source: JoinFaces 6.1.1 reference.

Mojarra Versus MyFaces

Everything on the security surface behaved the same on both implementations except the points below; the specification leaves them open.

Table 16. Differences observed (Mojarra 4.1.17, MyFaces 4.1.4)
Topic Mojarra MyFaces

Message of a ProtectedViewException for a foreign Referer or Origin

JSF1099 and JSF1100 name the header and the view

no message in the exception or the error page

Quote in escaped element text

" left as it is, <, > and & escaped

" written as &quot;

Attribute values

" escaped; ' not escaped (so never put data into a JavaScript string inside an attribute)

the same

Nonce and policy header for CSP

built in: com.sun.faces.enableCspNonce, com.sun.faces.cspPolicy (the standard names arrive in Faces 5.0)

none

f:ajax under PrimeFaces CSP

the Ajax update did not happen

worked

javascript: URLs

com.sun.faces.enableScriptsInAttributeValues set to false blanked the href

no equivalent parameter tested

Client state key

JNDI java:comp/env/faces/ClientSideSecretKey (Base64); random per start if absent

org.apache.myfaces.SECRET, MAC_SECRET; random per start if absent (INFO at start-up)

Server state identifier

random numbers by default (com.sun.faces.generateUniqueServerStateIds)

a Base64 token (parameter org.apache.myfaces.RANDOM_KEY_IN_VIEW_STATE_SESSION_TOKEN)

Own cookies

flash cookie not seen in these runs

oam.Flash.RENDERMAP.TOKEN with HttpOnly and SameSite=Lax

Container behaviour (authentication, constraints, session id rotation, headers) was the same on both because it belongs to Tomcat. The jakarta.faces.ViewState field is rendered with autocomplete="one-time-code" on both.

What Changed by Version

  • JSF 2.2 (Java EE 7, 2013): protected views (<protected-views>, ViewHandler.addProtectedView, ProtectedViewException, ResponseStateManager.NON_POSTBACK_VIEW_TOKEN_PARAM), stateless views and ClientWindow; the specification’s change list names "Specify Cross Site Request Forgery protection" (issue 869).

  • Java EE 8 / JSF 2.3 (2017): the Java EE Security API 1.0 (JSR 375): HttpAuthenticationMechanism, IdentityStore, SecurityContext, the FORM and custom FORM annotations. f:websocket arrives with its own security considerations.

  • Jakarta EE 9 / Faces 3.0: javax.* becomes jakarta.*, Jakarta Security 2.0.

  • Jakarta EE 10 / Faces 4.0 (2022): ExternalContext.addResponseCookie accepts arbitrary cookie attributes such as SameSite (issue 1570); Servlet 6.0 adds generic cookie attributes (cookie-config/attribute); Jakarta Security 3.0 adds OpenID Connect; Mojarra 4.0.16 adds com.sun.faces.enableCspNonce.

  • Jakarta EE 11 / Faces 4.1: Jakarta Security 4.0: a handler for several authentication mechanisms, qualifiers on the built-in mechanisms, the in-memory identity store, Java 17, no references to the Java SecurityManager (Faces issue 1838 removes them too). Full state saving is deprecated (issue 1829; see the state saving page).

  • Mojarra 4.0.16 and later (including 4.1): the vendor parameters com.sun.faces.enableCspNonce and com.sun.faces.cspPolicy (both since 4.0.16 in the Mojarra context parameter reference); earlier 4.0 releases have no CSP support of their own.

  • Faces 5.0 (upcoming; latest milestone 5.0.0-M9 on 2026-09-18): jakarta.faces.ENABLE_CSP_NONCE, jakarta.faces.CSP_POLICY, ResourceHandler.getCurrentNonce (issue 1590). Do not rely on names until a final release.

Where the Books Are Outdated

  • Ed Burns and Chris Schalk’s JavaServer Faces 2.0: The Complete Reference (2010) covers container-managed authentication and role checks in views. The idea is still right: the container authenticates, and rendered and security-constraint decide. What changed is how: the realms and j_security_check forms of that era were configured per server (a user file, a JDBC realm, a vendor login module); Jakarta Security gives an IdentityStore, a mechanism annotation and SecurityContext that work on any Jakarta EE server, and its listings use the javax packages. It predates protected views (2.2), SameSite cookies and CSP.

  • Anghel Leonard’s JSF 2.0 Cookbook (2010) discusses several approaches to security; the libraries of that time (the JSF Security project, Acegi, Seam 2) have ended. Use Jakarta Security, or Spring Security through JoinFaces.

  • All of them predate the data they now have to protect: cookie attributes, Content Security Policy, Sec-Fetch-* headers, PBKDF2 or Argon2 password storage with a work factor, and OpenID Connect. Treat any listing in a book that stores a password in clear text, compares one with equals, or puts a user name and a password into web.xml as historical.

Common Mistakes

  • Checking roles only in the view. rendered hides a button; it does not protect the method. Check in the service (interceptor or SecurityContext) and in web.xml.

  • A URL constraint for one name. /admin/panel.xhtml leaves /admin/panel open with extensionless mapping. Constrain /admin/*.

  • An extra FacesServlet mapping. /faces/* served a view that the constraint on /admin/* did not cover. Constrain /* or each prefix.

  • Listing HTTP methods in a constraint and forgetting deny-uncovered-http-methods: POST and PUT were open.

  • f:viewAction as the only guard. It does not run on postbacks unless onPostback="true".

  • Client state saving or stateless views on a page that changes data without another forgery defence. The view state is not a CSRF token there.

  • A state change on a GET (h:link, a view action that writes). Use a command button (POST) and keep the confirmation page read-only.

  • Protected views taken for authorisation. The token says "came from this application", not "may open this".

  • Data in a script string or an event-handler attribute. HTML escaping does not stop it. Use data-* attributes and script files.

  • escape="false" on data. Sanitise first, or do not allow markup.

  • javascript: and data: URLs from user input. Accept http:, https: and relative paths only.

  • Passwords with a fast hash or the default work factor. PBKDF2 with 2048 iterations is the API default; set 600,000 with SHA-256, or use a stronger function.

  • Different responses for unknown user and wrong password, and different timing. One message, one cost.

  • @InMemoryIdentityStoreDefinition or seed accounts in production. They exist for demos.

  • A password, client secret or key in an annotation, web.xml, faces-config.xml or a bean. Read them from the environment or the server.

  • Redirecting to a next parameter. Validate the path or keep the target on the server.

  • Not rotating the session id when the application authenticates by itself. Call changeSessionId().

  • A logout link. Logout is a POST.

  • Missing HttpOnly, Secure or SameSite on the session cookie, and session ids in URLs (<tracking-mode>COOKIE</tracking-mode>).

  • Security headers set only in the filter. The container’s own redirects and 403s do not pass through it.

  • Default error pages. They disclosed class names, stack frames and the server version. Map them.

  • Assuming Faces 4.1 implements the 5.0 CSP parameter. It does not; use the implementation’s own.

References