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.
| Layer | Where it is configured | What it can stop | What it cannot stop |
|---|---|---|---|
Container authentication and |
|
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 |
|
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; |
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) |
|
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 |
Service method |
|
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 |
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) |
Source: Jakarta Faces 4.1 specification, 2.2.1 Restore View and Jakarta EE Tutorial: Getting Started Securing Enterprise Applications
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:
| Annotation | Behaviour | Since |
|---|---|---|
|
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 |
|
Servlet FORM login: a login page with a plain HTML form that posts |
1.0 |
|
The same login-to-continue flow, but the application collects the credentials itself (a Faces form with validation, messages, Ajax) and calls |
1.0 |
|
OpenID Connect Authorization Code flow with refresh tokens: the login page is the provider’s |
3.0 |
Source: Jakarta Security 4.0 specification, 2.4 Annotations and Built-In HttpAuthenticationMechanism Beans and Jakarta Security 3.0 release page (OpenID Connect)
Three rules from the specification shape everything below.
-
The annotation replaces
login-config. If the application has anHttpAuthenticationMechanismand also alogin-configinweb.xml, the container may fail the deployment and otherwise must use only the mechanism. Deletelogin-configonce you move to the annotations. -
The container resolves roles, not you. A caller who belongs to group
foohas rolefooby default (specification 1.2.1), so the groups an identity store returns are the role names thatsecurity-constraint,isUserInRoleandSecurityContext.isCallerInRolesee. 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.
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 {
}
<!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>
Source: Jakarta Security 4.0 specification, 2.4.2 FORM Annotation and Jakarta EE Tutorial: Securing an Endpoint with Form Authentication
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:
| Status | Meaning | In the action method |
|---|---|---|
|
The authentication succeeded and the mechanism has sent (or will send) a redirect to the page the caller asked for first |
Call |
|
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 |
|
The credentials were rejected |
Add one generic message and re-render the form |
|
The mechanism did not run for this request |
Return |
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 {
}
<!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>
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;
}
}
Source: Jakarta Security 4.0 specification, 2.4.9 Custom FORM Notes and Jakarta Security 4.0 API: SecurityContext
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 defaulterrorPageis/login-error, and the specification redirects there only when the value is non-empty. -
useForwardToLogin = falsemakes 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 setspassword = nullas soon as it has used it. The bean is@RequestScoped: a password in a@ViewScopedor@SessionScopedbean would live in the session or in the view state (and, with client state saving, in the page). -
pt:autocomplete="current-password"andusernametell 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
SUCCESSbranch 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.
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().
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);
}
}
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;
}
}
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) {
}
Source: Jakarta Security 4.0 specification, 3 Identity Store and Jakarta Security 4.0 API: Pbkdf2PasswordHash
What the listings do, and why:
-
Passwords are hashed with a slow, salted function and never stored or logged.
Pbkdf2PasswordHashis 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
PBKDF2WithHmacSHA256as 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 parametersPbkdf2PasswordHash.Algorithm,Pbkdf2PasswordHash.Iterations,Pbkdf2PasswordHash.SaltSizeBytesandPbkdf2PasswordHash.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 implementPasswordHashyourself.) -
The injected
Pbkdf2PasswordHashis dependent-scoped, so every injection point is a separate instance that must be initialised;PasswordServiceholds the single configured instance. -
char[], notString.Password.getValue()returns the characters, andPassword.clear()andUsernamePasswordCredential.clearCredential()overwrite them, so a password does not have to linger as an immutableString. -
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.
AccountRepositoryis the stand-in for a JPA repository (see the persistence page); the demo accounts exist only whenBOOKSHELF_SEED_ADMIN_PASSWORDandBOOKSHELF_SEED_MEMBER_PASSWORDare set. A real deployment creates accounts through a registration or administration use case that callsregister, 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.
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.
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.
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 {
}
Source: Jakarta Security 4.0 API: OpenIdAuthenticationMechanismDefinition and Jakarta EE Tutorial: Securing an Endpoint with OpenID Connect Authentication
Settings that matter, all from the specification and the API:
-
providerURIis the issuer;/.well-known/openid-configurationis appended to discover the endpoints.clientIdandclientSecretare 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. -
redirectURIdefaults to${baseURL}/Callback(baseURLis 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). -
scopedefaults toopenid,emailandprofile. Ask for the least that the application needs. -
useNoncedefaults totrue; keep it.useSessiondefaults totrue(state and nonce in the HTTP session); withfalsethe specification requires a cookie that isHttpOnlyandSecure. -
claimsDefinitionchooses which claims name the caller and give the groups;logoutwithnotifyProvider = truecalls the provider’s end-session endpoint so that signing out of the application also signs out of the provider. -
tokenAutoRefreshrefreshes expired tokens with the refresh token;redirectToOriginalResourcerestores 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:
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.
| Expression or call | Where it works | Observed |
|---|---|---|
|
any Facelets expression ( |
|
|
any Facelets expression |
|
|
Java code that already has a |
identical to the request methods |
|
any CDI bean ( |
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. |
Source: Jakarta Security 4.0 specification, 4 Security Context and Jakarta Faces 4.1 API: ExternalContext
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:
<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>
Source: Jakarta Faces 4.1 VDL: h:commandButton and Jakarta EE Tutorial: Using Jakarta Faces Technology in Web Pages
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):
| Forged request | Result on Mojarra and MyFaces | Why |
|---|---|---|
Post |
nothing happened, no message, the counter stayed at 0 |
The component does not exist in the member’s tree; the parameter is ignored |
Post |
"denied by service"; the counter stayed at 0 |
The |
The administrator presses |
"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:
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();
}
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();
}
}
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:
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.
| Configuration | Mojarra 4.1.17 | MyFaces 4.1.4 |
|---|---|---|
Server state saving (the default) |
500, |
500, |
Client state, |
200, the action ran: the counter became 1 |
200, the action ran |
Client state, |
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.
<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>
Source: Jakarta Servlet 6.1 specification, Security and Jakarta EE Tutorial: Getting Started Securing Enterprise Applications
-
<role-name>**</role-name>means "any authenticated caller, whatever the roles" (Servlet 3.1 and later);*means any role declared withsecurity-role. The member and the administrator both reached/members/*. -
<deny-uncovered-http-methods/>(Servlet 3.1 and later) answers403to every method the constraints do not mention. See the second table below for what happens without it. -
A
transport-guaranteeofCONFIDENTIALmakes the container refuse plain HTTP for the URLs: the scratch run answered302to thehttps://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-roledeclares 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.
| Request | Constraint /admin/* (the example) |
Constraint on the exact /admin/panel.xhtml only |
Constraint /admin/*, plus an additional /faces/* mapping of the servlet |
|---|---|---|---|
|
302 |
302 |
302 |
|
302 |
200 |
302 |
|
404 |
404 |
200 |
|
302 |
302 |
302 |
|
302 |
404 |
302 |
-
Constrain a directory, not a file name.
/admin/*covered the.xhtmlname, the extensionless name and every path variation tried. A constraint written for/admin/panel.xhtmlleft/admin/panelopen oncejakarta.faces.AUTOMATIC_EXTENSIONLESS_MAPPING(Faces 4.0) was on. -
Every mapping of the
FacesServletis a second door. With/faces/*mapped as well,/faces/admin/panel.xhtmlbypassed 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.
| Configuration | GET | POST | PUT |
|---|---|---|---|
|
302 |
200 |
200 |
The same constraint with |
302 |
403 |
403 |
No |
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:
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.
| Configuration | Mojarra 4.1.17 | MyFaces 4.1.4 |
|---|---|---|
Server state saving (the default) |
500 |
500 |
Client state saving ( |
200, the action ran as the victim (counter 0 to 1) |
200, the action ran as the victim |
Stateless view ( |
200: no cookie, no earlier request, the constant value |
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.
<?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>
<!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>
Source: Jakarta Faces 4.1 specification, 11.3.5 Application Configuration Resource Format, Jakarta Faces 4.1 API: ViewHandler.getProtectedViewsUnmodifiable and Jakarta Faces 4.1 API: ResponseStateManager
How it works, per the specification (Restore View, "View Protection") and as observed on Mojarra and MyFaces:
-
<protected-views>holdsurl-patternelements.ViewHandler.addProtectedView,removeProtectedViewandgetProtectedViewsUnmodifiableread and change the same set at runtime. -
Links and redirects that the Faces components generate to a protected view get a token:
h:linkinindex.xhtmlrendered/bookshelf/admin/confirm-delete.xhtml?jakarta.faces.Token=...(the parameter name isResponseStateManager.NON_POSTBACK_VIEW_TOKEN_PARAM, the value comes fromgetCryptographicallyStrongTokenFromSession). 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
RefererorOriginheader is present it must name a view of the application.
| Request | Mojarra | MyFaces |
|---|---|---|
GET without a token (as from a typed address or another site) |
500 |
500 |
GET with the token from the link |
200 |
200 |
GET with a wrong token |
500 |
500 |
GET with the token and |
500, |
500, no message |
GET with the token and |
500, |
500, no message |
GET with the token and |
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:viewActionthat 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.
ProtectedViewExceptionis not aViewExpiredException; map it to your own403page with an<error-page>forjakarta.faces.application.ProtectedViewException(observed on Mojarra: the mapped static page was served and the status stayed 500) or handle it in a customExceptionHandlerthat 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.Tokenproves 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:
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):
| Header | Status |
|---|---|
none (old browsers, scripts, |
200 |
|
403 |
|
200 |
|
403 |
|
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.
| Context | Output | Safe? |
|---|---|---|
|
|
Yes |
|
raw: |
No. The data is markup |
|
always escaped ( |
Yes |
An HTML attribute: |
|
Yes, for the attribute |
A script string: |
HTML-escaped but not JavaScript-escaped: with the value |
No |
An event-handler attribute: |
The |
No |
A URL attribute: |
|
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:
<!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>
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).escapeexists onh:outputText,h:outputFormat,h:outputLabeland, asitemEscaped/itemLabelEscaped, onf:selectItemandf:selectItems(all defaulttruein the VDL). Component libraries add their own:p:message,p:messagesandp:growlescape by default and have anescapeattribute (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(andp:growl) message withescape="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:chartwrites its JSON into an inline script without escaping, so a label containing a closing script tag injected markup (PrimeFaces charts); thep:timelineevent data of PrimeFaces Extensions is shown as HTML inside an inline script (PrimeFaces Extensions); andb:growlof BootsFaces does not escape unlessescape="true"is set, whileb:messagesdoes (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 withh:outputFormatand 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 (withrel="nofollow") and dropped theimg, thejavascript: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.javapackage 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 withtextContent, neverinnerHTML(see the push security notes). -
Check URL schemes. Mojarra can refuse them globally: with
com.sun.faces.enableScriptsInAttributeValuesset tofalsetheh:outputLinkabove was rendered withhref=""instead of thejavascript: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 onlyhttps:,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:selectOneMenuis answered withValidation Error: Value is not validand the model is not touched (both implementations). Hidden fields,f:param, request parameters andf:viewParamhave 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:
| Setup | Header and rendering | Behaviour |
|---|---|---|
Mojarra 4.1.17 with |
|
A command button with |
The standard parameter |
no |
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 |
|
|
Source: Eclipse Mojarra 4.1: context parameters, PrimeFaces 16 documentation: Content Security Policy and W3C: Content Security Policy Level 3
<!-- 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>
Source: Eclipse Mojarra 4.1: context parameters and PrimeFaces 16 documentation: Content Security Policy
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.
<!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):
<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:
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:
| Step | Result |
|---|---|
Victim logs in carrying the attacker’s id |
|
Attacker requests |
anonymous ( |
Attacker requests |
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:
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:
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: privatewas 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-Policywith 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). Mapjava.lang.Throwableand the status codes to a static page; the mapping served the page and kept the status (observed on Mojarra). HandleViewExpiredExceptionandProtectedViewExceptionspecifically, and on Tomcat also switch off the report of theErrorReportValve: 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_STAGEProduction(the default). TheDevelopmentstage 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.disableClientStateEncryptionororg.apache.myfaces.USE_ENCRYPTIONto look at the state; the encrypted, authenticated value is what keeps a forged serialised object out of the server. -
Push.
f:websockethas its own checklist (protect/jakarta.faces.push/<channel>, never trust auserfrom 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, theconparameter 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.
| Secret | Supplied by |
|---|---|
Account passwords |
Never stored; PBKDF2 hashes in the database ( |
Seed passwords of a demo |
Environment variables of the process ( |
OpenID Connect client id and secret |
Environment variables read by |
Database and LDAP credentials |
The server’s data source and naming configuration; expressions in the annotation |
Client state keys |
JNDI entry |
Public origin of the application |
|
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.
| Topic | Mojarra | MyFaces |
|---|---|---|
Message of a |
|
no message in the exception or the error page |
Quote in escaped element text |
|
|
Attribute values |
|
the same |
Nonce and policy header for CSP |
built in: |
none |
|
the Ajax update did not happen |
worked |
|
|
no equivalent parameter tested |
Client state key |
JNDI |
|
Server state identifier |
random numbers by default ( |
a Base64 token (parameter |
Own cookies |
flash cookie not seen in these runs |
|
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 andClientWindow; 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:websocketarrives with its own security considerations. -
Jakarta EE 9 / Faces 3.0:
javax.*becomesjakarta.*, Jakarta Security 2.0. -
Jakarta EE 10 / Faces 4.0 (2022):
ExternalContext.addResponseCookieaccepts arbitrary cookie attributes such asSameSite(issue 1570); Servlet 6.0 adds generic cookie attributes (cookie-config/attribute); Jakarta Security 3.0 adds OpenID Connect; Mojarra 4.0.16 addscom.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.enableCspNonceandcom.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
renderedandsecurity-constraintdecide. What changed is how: the realms andj_security_checkforms of that era were configured per server (a user file, a JDBC realm, a vendor login module); Jakarta Security gives anIdentityStore, a mechanism annotation andSecurityContextthat work on any Jakarta EE server, and its listings use thejavaxpackages. 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 withequals, or puts a user name and a password intoweb.xmlas historical.
Common Mistakes
-
Checking roles only in the view.
renderedhides a button; it does not protect the method. Check in the service (interceptor orSecurityContext) and inweb.xml. -
A URL constraint for one name.
/admin/panel.xhtmlleaves/admin/panelopen with extensionless mapping. Constrain/admin/*. -
An extra
FacesServletmapping./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:viewActionas the only guard. It does not run on postbacks unlessonPostback="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:anddata:URLs from user input. Accepthttp:,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.
-
@InMemoryIdentityStoreDefinitionor seed accounts in production. They exist for demos. -
A password, client secret or key in an annotation,
web.xml,faces-config.xmlor a bean. Read them from the environment or the server. -
Redirecting to a
nextparameter. 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,SecureorSameSiteon 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
-
Jakarta EE Tutorial: Getting Started Securing Enterprise Applications
-
Jakarta Security 4.0 API: FormAuthenticationMechanismDefinition
-
Jakarta Security 4.0 API: CustomFormAuthenticationMechanismDefinition
-
Jakarta Security 4.0 API: OpenIdAuthenticationMechanismDefinition
-
Jakarta Faces 4.1 specification, 11.3.5 Application Configuration Resource Format
-
Jakarta Faces 4.1 specification, B.5 Changes between 2.1 and 2.2
-
Jakarta Faces 4.1 specification, B.2 Changes between 4.0 and 3.0
-
Jakarta EE Tutorial: Using Jakarta Faces Technology in Web Pages
-
Jakarta Faces issue 1570: custom cookie attributes in addResponseCookie
-
Burns and Schalk, JavaServer Faces 2.0: The Complete Reference (publisher page)