Conditional MFA in Spring Security 7: OIDC vs Password
Implement conditional MFA in Spring Security 7: skip email OTT for OIDC providers like Google/Facebook, enforce password + OTT for username/password logins. Custom handlers, managers, and full code examples for provider-based security.
How to customize conditional Multi-Factor Authentication (MFA) in Spring Security 7 based on authentication method?
I need to implement MFA in Spring Security 7 such that:
- Users authenticating via OIDC providers (e.g., Google or Facebook) can access the application directly after successful login.
- Users using username/password authentication must additionally verify via email one-time token (OTT).
In other words, require both password and OTT factors for username/password flows, but only the OIDC authentication for OAuth2 providers.
Current configuration:
@EnableMultiFactorAuthentication(authorities = {
FactorGrantedAuthority.PASSWORD_AUTHORITY,
FactorGrantedAuthority.OTT_AUTHORITY
})
This enforces both factors universally, but for OIDC logins (e.g., Google), authentication is handled at the API gateway. How can I make MFA conditional on the authentication provider?
To customize conditional MFA in Spring Security 7, implement a custom AuthenticationSuccessHandler that detects OIDC providers like Google or Facebook—skipping the email OTT challenge—and a tailored AuthorizationManager that enforces password + OTT only for username/password flows. This bypasses the universal enforcement of @EnableMultiFactorAuthentication while keeping your API gateway’s OIDC tokens intact. You’ll detect providers via OAuth2AuthenticationToken checks and route accordingly, ensuring seamless access for social logins.
Contents
- Understanding Spring Security MFA and Why Conditional Setup is Needed
- Limitations of @EnableMultiFactorAuthentication
- Setting Up Dependencies and Basic SecurityFilterChain
- Implementing Custom AuthenticationSuccessHandler for Provider Detection
- Creating Conditional AuthorizationManager for OTT Challenge
- OTT Verification Controller and Token Handling
- Integrating with OIDC Providers and API Gateways
- Testing and Best Practices for Conditional MFA
- Sources
- Conclusion
Understanding Spring Security MFA and Why Conditional Setup is Needed
Spring Security 7 introduced robust MFA support through annotations like @EnableMultiFactorAuthentication and factories like AuthorizationManagerFactories.multiFactor(). But here’s the catch: it applies universally by default. For apps with mixed auth—username/password plus OIDC from Google or Facebook—this forces an extra OTT step on everyone, frustrating users who already verified via their provider.
Why bother with conditional spring security MFA? Security stays tight for risky password logins (password + email OTT), while OIDC gets a pass since providers handle strong factors already. The Spring Security documentation outlines programmatic MFA with FactorGrantedAuthority constants, but you need custom logic to differentiate providers. Think about it: your API gateway passes OIDC tokens as authorities—perfect for detection.
This setup leverages TwoFactorAuthentication tokens and explicit SecurityContext saves, avoiding full re-auth flows.
Limitations of @EnableMultiFactorAuthentication
That @EnableMultiFactorAuthentication(authorities = {PASSWORD_AUTHORITY, OTT_AUTHORITY}) line? It’s great for blanket coverage but blind to auth methods. OIDC logins via /oauth2/authorization/google complete at the gateway, yet Spring still demands OTT, redirecting to a challenge page unnecessarily.
Users on Stack Overflow echo this pain—posts like this one highlight the gap, with no simple override. The annotation bootstraps global rules via RequiredAuthoritiesRepository, but lacks provider-based conditionals out of the box. Spring’s official blog teases endpoint-specific tweaks, yet for auth-provider splits, you dive into SecurityFilterChain customizations.
Skip the annotation’s rigidity. Build targeted handlers instead—more flexible, same security.
Setting Up Dependencies and Basic SecurityFilterChain
Start simple. Add these to pom.xml (Maven) or build.gradle:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
No extra MFA libs needed—MFA ships in Security 7 core.
Wire a SecurityFilterChain bean, ditching the annotation for control:
@Bean
@Order(1)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/challenge/ott").access(conditionalMfaManager())
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.successHandler(conditionalMfaSuccessHandler())
)
.formLogin(form -> form
.successHandler(conditionalMfaSuccessHandler())
.permitAll()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
This funnels both flows through your custom handler. Notice /challenge/ott guarded by the manager we’ll build next.
Implementing Custom AuthenticationSuccessHandler for Provider Detection
Detection is key for conditional spring security MFA. Create ConditionalMfaSuccessHandler:
@Component
public class ConditionalMfaSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
if (authentication instanceof OAuth2AuthenticationToken) {
// OIDC from Google/Facebook: direct access
response.sendRedirect("/dashboard");
return;
}
// Username/password: start OTT flow
TwoFactorAuthentication twoFactorAuth = new TwoFactorAuthentication(
authentication, FactorGrantedAuthority.PASSWORD_AUTHORITY.name());
SecurityContextHolder.getContext().setAuthentication(twoFactorAuth);
SecurityContextHolder.getContext().setRequireExplicitSave(true);
response.sendRedirect("/challenge/ott");
}
}
Boom. instanceof OAuth2AuthenticationToken sniffs OIDC (gateway-propagated). Password users get wrapped in TwoFactorAuthentication with PASSWORD_AUTHORITY, forcing OTT. Inject this via successHandler(conditionalMfaSuccessHandler()).
Credit to detailed guides like this ik.am entry for the wrapper pattern—it ensures the context demands the second factor without full logout.
Short and sweet: OIDC skips, passwords challenge.
Creating Conditional AuthorizationManager for OTT Challenge
Protect /challenge/ott with brains. Extend AuthorizationManager:
@Component
public class ConditionalMfaAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext context) {
Authentication auth = authentication.get();
if (!(auth instanceof TwoFactorAuthentication twoFactorAuth)) {
return new AuthorizationDecision(false);
}
// Enforce PASSWORD + OTT only if not OIDC-augmented
boolean hasPassword = twoFactorAuth.getFactors().contains(PASSWORD_AUTHORITY.name());
boolean hasOtt = auth.getAuthorities().contains(OTT_AUTHORITY);
return new AuthorizationDecision(hasPassword && hasOtt);
}
}
Wire it: @Bean public AuthorizationManager<RequestAuthorizationContext> conditionalMfaManager() { return new ConditionalMfaAuthorizationManager(); }
This checks factors post-OTT submit. No OIDC pretense here—pure enforcement for password flows. The official docs endorse such managers for dynamic rules, beating static annotations.
What if gateway adds OIDC authorities? Tweak the check to bail if ROLE_OIDC or similar exists.
OTT Verification Controller and Token Handling
Handle OTT submission. Generate email tokens via your service (e.g., JavaMailSender), then verify:
@PostMapping("/challenge/ott")
public String verifyOtt(@RequestParam String token, HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth instanceof TwoFactorAuthentication twoFactorAuth) || !ottService.validate(token, twoFactorAuth.getPrincipal().toString())) {
return "ott-failed"; // Or redirect with error
}
// Restore full auth with OTT authority
UsernamePasswordAuthenticationToken fullAuth = new UsernamePasswordAuthenticationToken(
twoFactorAuth.getPrincipal(), null,
AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER, " + OTT_AUTHORITY.getAuthority()));
fullAuth.setDetails(twoFactorAuth.getDetails());
SecurityContextHolder.getContext().setAuthentication(fullAuth);
SecurityContextHolder.getContext().setRequireExplicitSave(false);
return "redirect:/dashboard";
}
ottService.validate() checks expiry/DB match. Expose /challenge/ott publicly but manager-guarded. Dan Vega’s tutorial inspires the factory angle, but adapt for OTT.
Pro tip: Use .oneTimeTokenLogin(Customizer.withDefaults()) if expanding to TOTP later.
Integrating with OIDC Providers and API Gateways
OIDC shines here. Configure clients:
spring:
security:
oauth2:
client:
registration:
google:
client-id: your-id
client-secret: your-secret
provider:
google:
authorization-uri: https://accounts.google.com/o/oauth2/auth
token-uri: https://oauth2.googleapis.com/token
Gateway forwards JWTs as OAuth2AuthenticationToken—your handler detects via registeredClientId. Facebook? Same registration block.
Edge case: Gateway auth headers. Add authoritiesExtractor in oauth2ResourceServer to map ROLE_OIDC_GOOGLE. Handler skips if present.
Test flow: Google login → gateway → app → direct dashboard. Password → form → OTT email → verify → dashboard.
Testing and Best Practices for Conditional MFA
Fire up Postman or curl:
- Password: POST
/login→ redirect/challenge/ott→ POST token → dashboard. - OIDC: Hit
/oauth2/authorization/google→ callback → dashboard (no OTT).
Best practices? Log factors in handlers. Rate-limit OTT sends. Fallback to TOTP for mobile. Monitor with Spring Actuator.
Common pitfalls: Forgetting requireExplicitSave(false)—blocks requests. Or missing .securityContext(ctx -> ctx.requireExplicitSave(false)) in chains.
Scale it: Conditional 2FA spring security handles high traffic fine. Debug with logging.level.org.springframework.security=DEBUG.
Sources
- Spring Security MFA Reference — Official guide to programmatic MFA and AuthorizationManager usage: https://docs.spring.io/spring-security/reference/servlet/authentication/mfa.html
- Multi-Factor Authentication in Spring Security 7 — Spring blog on MFA factories and @EnableMultiFactorAuthentication: https://spring.io/blog/2025/10/21/multi-factor-authentication-in-spring-security-7/
- Customizing MFA in Spring Security 7 — ik.am tutorial with conditional handler and OIDC detection code: https://ik.am/entries/763/en
- Spring Security 7 Multi-Factor Authentication — Dan Vega blog with AuthorizationManager examples and filter chain setup: https://www.danvega.dev/blog/spring-security-7-multi-factor-authentication
- Customizing MFA in Spring Security 7 — Stack Overflow discussion on universal MFA limitations for OIDC: https://stackoverflow.com/questions/79865943/customizing-mfa-in-spring-security-7
Conclusion
Conditional MFA in Spring Security 7 nails the balance: OIDC users (Google, Facebook) breeze in post-gateway, while password logins demand that extra OTT layer. Key wins? Custom AuthenticationSuccessHandler for detection, AuthorizationManager for enforcement, and clean token restores. Drop the annotation, embrace these beans—your app’s secure and user-friendly. Tweak for your gateway, test flows, and scale confidently.