Summary
Wrap Spring Security 7's built-in Multi-Factor Authentication infrastructure in simple user.mfa.* configuration properties, consistent with how we wrap passkeys via user.webauthn.*.
A consuming app would enable MFA with:
user.mfa.enabled: true
user.mfa.factors: PASSWORD, WEBAUTHN
This makes ALL authenticated endpoints require all configured factors. Spring Security 7 handles the enforcement, redirection between factor login pages, and session management automatically.
Background & Research
Spring Security 7 (shipped with Spring Boot 4, which we already target) has built-in MFA via:
FactorGrantedAuthority — automatically added to Authentication on each successful auth step
FactorGrantedAuthority.WEBAUTHN_AUTHORITY — predefined constant for passkeys (our WebAuthnAuthenticationProvider already adds this)
FactorGrantedAuthority.PASSWORD_AUTHORITY — for password login
@EnableMultiFactorAuthentication — annotation to globally require multiple factors
AuthorizationManagerFactories.multiFactor() — programmatic per-endpoint factor requirements
DelegatingMissingAuthorityAccessDeniedHandler — redirects partially-authenticated users to the correct factor's login page
Current State
Our WebAuthnAuthenticationSuccessHandler already preserves FactorGrantedAuthority.WEBAUTHN_AUTHORITY through the principal conversion. The MFA infrastructure essentially works today — a consuming app could manually use @EnableMultiFactorAuthentication with our library. This issue is about making it easy.
Compliance Context
- PCI DSS 4.x (FAQ 1596): Passkeys satisfy MFA alone if factors are separate and secure, but many auditors prefer explicit two-step auth
- NIST SP 800-63-4: Synced passkeys can achieve AAL2; AAL2 must offer phishing-resistant option
- Industry: Keycloak, Auth0, Okta all support passkeys as both password replacement AND MFA — configurable per-deployment
Implementation Plan
New Files (~6)
-
MfaConfigProperties.java — @ConfigurationProperties(prefix = "user.mfa")
boolean enabled (default: false)
List<String> factors (e.g., PASSWORD, WEBAUTHN)
String passwordEntryPointUri — redirect when PASSWORD factor missing
String webauthnEntryPointUri — redirect when WEBAUTHN factor missing
-
MfaConfiguration.java — @Configuration + @ConditionalOnProperty(name = "user.mfa.enabled")
- Programmatically replicates
@EnableMultiFactorAuthentication (which needs compile-time constants)
- Bean:
DefaultAuthorizationManagerFactory<?> via AuthorizationManagerFactories.multiFactor().requireFactors(...) — makes .authenticated() require all factors
- Bean:
BeanPostProcessor that calls setMfaEnabled(true) on all auth filters
- Static helper:
mapFactorToAuthority("PASSWORD") → FactorGrantedAuthority.PASSWORD_AUTHORITY
- Startup validation: error if factors empty, error if WEBAUTHN in factors but webauthn disabled, warning re: passwordless accounts + PASSWORD factor
-
MfaStatusResponse.java — DTO: mfaEnabled, requiredFactors, satisfiedFactors, missingFactors, fullyAuthenticated
-
MfaAPI.java — @RestController at /user/mfa
GET /user/mfa/status — returns which factors are required/satisfied/missing for current session
- Must be accessible to partially-authenticated users (added to unprotected URIs)
Modified Files (~4)
-
WebSecurityConfig.java
- Inject
ObjectProvider<MfaConfigProperties>
- Add
setupMfa() — configures DelegatingMissingAuthorityAccessDeniedHandler
- Update
getUnprotectedURIsList() — add /user/mfa/** when MFA enabled
-
dsspringuserconfig.properties — add MFA defaults
-
WebAuthnAuthenticationSuccessHandlerTest.java — add test verifying FactorGrantedAuthority.WEBAUTHN_AUTHORITY preservation
-
New test files — MfaConfigurationTest, MfaAPITest, MfaSecurityTest
Edge Cases
- Passwordless accounts + PASSWORD factor: Users with passkey-only accounts can't satisfy PASSWORD. Startup warning + documentation.
- OAuth2 + MFA coexistence: OAuth2 uses
authenticationEntryPoint (unauthenticated). MFA uses accessDeniedHandler (partially-authenticated). No conflict.
- WebAuthn endpoints during MFA flow: Filters process before authorization — no URI exemptions needed.
Out of Scope (Future)
- Per-URI step-up authentication (
user.mfa.protectedURIs)
- Factor validity duration (
user.mfa.factorValidity: 30m)
- MFA challenge UI pages (see companion issue in DemoApp repo)
Related
Summary
Wrap Spring Security 7's built-in Multi-Factor Authentication infrastructure in simple
user.mfa.*configuration properties, consistent with how we wrap passkeys viauser.webauthn.*.A consuming app would enable MFA with:
This makes ALL authenticated endpoints require all configured factors. Spring Security 7 handles the enforcement, redirection between factor login pages, and session management automatically.
Background & Research
Spring Security 7 (shipped with Spring Boot 4, which we already target) has built-in MFA via:
FactorGrantedAuthority— automatically added toAuthenticationon each successful auth stepFactorGrantedAuthority.WEBAUTHN_AUTHORITY— predefined constant for passkeys (ourWebAuthnAuthenticationProvideralready adds this)FactorGrantedAuthority.PASSWORD_AUTHORITY— for password login@EnableMultiFactorAuthentication— annotation to globally require multiple factorsAuthorizationManagerFactories.multiFactor()— programmatic per-endpoint factor requirementsDelegatingMissingAuthorityAccessDeniedHandler— redirects partially-authenticated users to the correct factor's login pageCurrent State
Our
WebAuthnAuthenticationSuccessHandleralready preservesFactorGrantedAuthority.WEBAUTHN_AUTHORITYthrough the principal conversion. The MFA infrastructure essentially works today — a consuming app could manually use@EnableMultiFactorAuthenticationwith our library. This issue is about making it easy.Compliance Context
Implementation Plan
New Files (~6)
MfaConfigProperties.java—@ConfigurationProperties(prefix = "user.mfa")boolean enabled(default: false)List<String> factors(e.g., PASSWORD, WEBAUTHN)String passwordEntryPointUri— redirect when PASSWORD factor missingString webauthnEntryPointUri— redirect when WEBAUTHN factor missingMfaConfiguration.java—@Configuration+@ConditionalOnProperty(name = "user.mfa.enabled")@EnableMultiFactorAuthentication(which needs compile-time constants)DefaultAuthorizationManagerFactory<?>viaAuthorizationManagerFactories.multiFactor().requireFactors(...)— makes.authenticated()require all factorsBeanPostProcessorthat callssetMfaEnabled(true)on all auth filtersmapFactorToAuthority("PASSWORD")→FactorGrantedAuthority.PASSWORD_AUTHORITYMfaStatusResponse.java— DTO:mfaEnabled,requiredFactors,satisfiedFactors,missingFactors,fullyAuthenticatedMfaAPI.java—@RestControllerat/user/mfaGET /user/mfa/status— returns which factors are required/satisfied/missing for current sessionModified Files (~4)
WebSecurityConfig.javaObjectProvider<MfaConfigProperties>setupMfa()— configuresDelegatingMissingAuthorityAccessDeniedHandlergetUnprotectedURIsList()— add/user/mfa/**when MFA enableddsspringuserconfig.properties— add MFA defaultsWebAuthnAuthenticationSuccessHandlerTest.java— add test verifyingFactorGrantedAuthority.WEBAUTHN_AUTHORITYpreservationNew test files —
MfaConfigurationTest,MfaAPITest,MfaSecurityTestEdge Cases
authenticationEntryPoint(unauthenticated). MFA usesaccessDeniedHandler(partially-authenticated). No conflict.Out of Scope (Future)
user.mfa.protectedURIs)user.mfa.factorValidity: 30m)Related