Summary
With user.mfa.enabled=true and factors: PASSWORD, WEBAUTHN, completing the WebAuthn challenge after a password login replaces the session's authentication instead of adding the WEBAUTHN factor to it. The session goes from satisfiedFactors=[PASSWORD] to satisfiedFactors=[WEBAUTHN] — never both — so AllRequiredFactorsAuthorizationManager keeps denying access and the user bounces between the two factor entry points forever. The MFA flow introduced in #272 can never complete.
Reproduction
Found while testing the companion demo app PR (SpringUserFrameworkDemoApp#60) with a Playwright E2E test using Chromium's CDP virtual authenticator:
- Enable MFA (
user.mfa.enabled=true, factors: PASSWORD, WEBAUTHN)
- Log in with username/password →
GET /user/mfa/status reports satisfiedFactors=["PASSWORD"], missingFactors=["WEBAUTHN"]
- Complete a WebAuthn assertion (
POST /login/webauthn)
GET /user/mfa/status now reports satisfiedFactors=["WEBAUTHN"], missingFactors=["PASSWORD"], fullyAuthenticated=false
- Any protected request is denied for the missing
PASSWORD factor and redirected to passwordEntryPointUri; logging in with the password again drops WEBAUTHN, and so on
Root cause
Spring Security 7's factor accumulation is implemented in AbstractAuthenticationProcessingFilter.doFilter: when its mfaEnabled flag is set and the new authentication has the same name as the current one, the new authentication is rebuilt via toBuilder() with the union of both authorities (this is how FactorGrantedAuthoritys accumulate across logins).
That flag is off by default. It is normally turned on by @EnableMultiFactorAuthentication, whose import (EnableMfaFiltersConfiguration) registers a BeanPostProcessor that calls setMfaEnabled(true) on every AbstractAuthenticationProcessingFilter, AuthenticationFilter, AbstractPreAuthenticatedProcessingFilter, and BasicAuthenticationFilter.
The framework's MfaConfiguration replicates the authorization half of that annotation (it builds the DefaultAuthorizationManagerFactory with AllRequiredFactorsAuthorizationManager from user.mfa.factors), but nothing performs the filter half — mfaEnabled is never set, so authorities are never merged and each login replaces the previous factor.
Suggested fix
When user.mfa.enabled=true, MfaConfiguration should also register the filter post-processor, e.g.:
@Bean
@ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true")
static BeanPostProcessor mfaFilterEnabler() {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof AbstractAuthenticationProcessingFilter filter) {
filter.setMfaEnabled(true);
}
if (bean instanceof AuthenticationFilter filter) {
filter.setMfaEnabled(true);
}
if (bean instanceof AbstractPreAuthenticatedProcessingFilter filter) {
filter.setMfaEnabled(true);
}
if (bean instanceof BasicAuthenticationFilter filter) {
filter.setMfaEnabled(true);
}
return bean;
}
};
}
(HttpSecurity-built filters go through AutowireBeanFactoryObjectPostProcessor.initializeBean, so a BeanPostProcessor reaches them.)
Note: WebAuthnAuthenticationSuccessHandler is fine as-is — it preserves authentication.getAuthorities(), and the merge happens in the filter before the success handler runs, so the converted token keeps the merged factors.
Workaround
Consuming apps can add the missing half themselves (this is what the demo app now does in MfaSecurityConfig):
@Configuration
@ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true")
@EnableMultiFactorAuthentication(authorities = {})
public class MfaSecurityConfig { }
The empty authorities attribute skips Spring's AuthorizationManagerFactoryConfiguration (the framework already provides that bean) and only imports the filter post-processor.
Related observation
While debugging this, a second integration gap surfaced: WebSecurityConfig.getUnprotectedURIsList() auto-unprotects /user/mfa/status when MFA is enabled, but not the configured passwordEntryPointUri / webauthnEntryPointUri. If a consuming app forgets to add its WebAuthn entry point page to unprotectedURIs, partially-authenticated users are redirected to a page they are denied access to — an infinite redirect loop (ERR_TOO_MANY_REDIRECTS). Consider auto-adding the configured entry point URIs the same way /user/mfa/status is added. Happy to split this into its own issue if preferred.
Environment
Summary
With
user.mfa.enabled=trueandfactors: PASSWORD, WEBAUTHN, completing the WebAuthn challenge after a password login replaces the session's authentication instead of adding theWEBAUTHNfactor to it. The session goes fromsatisfiedFactors=[PASSWORD]tosatisfiedFactors=[WEBAUTHN]— never both — soAllRequiredFactorsAuthorizationManagerkeeps denying access and the user bounces between the two factor entry points forever. The MFA flow introduced in #272 can never complete.Reproduction
Found while testing the companion demo app PR (SpringUserFrameworkDemoApp#60) with a Playwright E2E test using Chromium's CDP virtual authenticator:
user.mfa.enabled=true,factors: PASSWORD, WEBAUTHN)GET /user/mfa/statusreportssatisfiedFactors=["PASSWORD"], missingFactors=["WEBAUTHN"]POST /login/webauthn)GET /user/mfa/statusnow reportssatisfiedFactors=["WEBAUTHN"], missingFactors=["PASSWORD"],fullyAuthenticated=falsePASSWORDfactor and redirected topasswordEntryPointUri; logging in with the password again dropsWEBAUTHN, and so onRoot cause
Spring Security 7's factor accumulation is implemented in
AbstractAuthenticationProcessingFilter.doFilter: when itsmfaEnabledflag is set and the new authentication has the same name as the current one, the new authentication is rebuilt viatoBuilder()with the union of both authorities (this is howFactorGrantedAuthoritys accumulate across logins).That flag is off by default. It is normally turned on by
@EnableMultiFactorAuthentication, whose import (EnableMfaFiltersConfiguration) registers aBeanPostProcessorthat callssetMfaEnabled(true)on everyAbstractAuthenticationProcessingFilter,AuthenticationFilter,AbstractPreAuthenticatedProcessingFilter, andBasicAuthenticationFilter.The framework's
MfaConfigurationreplicates the authorization half of that annotation (it builds theDefaultAuthorizationManagerFactorywithAllRequiredFactorsAuthorizationManagerfromuser.mfa.factors), but nothing performs the filter half —mfaEnabledis never set, so authorities are never merged and each login replaces the previous factor.Suggested fix
When
user.mfa.enabled=true,MfaConfigurationshould also register the filter post-processor, e.g.:(HttpSecurity-built filters go through
AutowireBeanFactoryObjectPostProcessor.initializeBean, so aBeanPostProcessorreaches them.)Note:
WebAuthnAuthenticationSuccessHandleris fine as-is — it preservesauthentication.getAuthorities(), and the merge happens in the filter before the success handler runs, so the converted token keeps the merged factors.Workaround
Consuming apps can add the missing half themselves (this is what the demo app now does in
MfaSecurityConfig):The empty
authoritiesattribute skips Spring'sAuthorizationManagerFactoryConfiguration(the framework already provides that bean) and only imports the filter post-processor.Related observation
While debugging this, a second integration gap surfaced:
WebSecurityConfig.getUnprotectedURIsList()auto-unprotects/user/mfa/statuswhen MFA is enabled, but not the configuredpasswordEntryPointUri/webauthnEntryPointUri. If a consuming app forgets to add its WebAuthn entry point page tounprotectedURIs, partially-authenticated users are redirected to a page they are denied access to — an infinite redirect loop (ERR_TOO_MANY_REDIRECTS). Consider auto-adding the configured entry point URIs the same way/user/mfa/statusis added. Happy to split this into its own issue if preferred.Environment
MfaSecurityConfig+ Playwright virtual-authenticator E2E that locks the behavior in)