Programming

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.

1 answer 1 view

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:

java
@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

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:

xml
<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:

java
@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:

java
@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:

java
@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:

java
@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:

yaml
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

  1. Spring Security MFA Reference — Official guide to programmatic MFA and AuthorizationManager usage: https://docs.spring.io/spring-security/reference/servlet/authentication/mfa.html
  2. 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/
  3. Customizing MFA in Spring Security 7ik.am tutorial with conditional handler and OIDC detection code: https://ik.am/entries/763/en
  4. 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
  5. 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.

Authors
Verified by moderation
Moderation
Conditional MFA in Spring Security 7: OIDC vs Password