Using Spring Security 7’s MFA support to perform two‑factor authentication (2FA) with TOTP

In a previous article we covered how to do two‑factor authentication (2FA) with Spring Security. Since Spring Security 7 now supports Multi‑Factor Authentication (MFA), this time we’ll implement a Time‑based One‑Time Password (TOTP)‑based 2FA within that framework, just like before.
TOTP is not supported as a built‑in Factor in Spring Security 7, so a custom implementation is required.

Warning

After the previous article, Spring Security added support for One‑Time Token (OTT) authentication (e.g., sending a token via email each time) and Passkey authentication in version 6.5, and both are built‑in to MFA.
In this context, you should carefully consider whether adopting TOTP now is the right choice. This article is written from the perspective of “how would the previous article look if we rewrote it using Spring Security 7’s MFA support”.

The sample application repository is here:
https://github.com/making/demo-2fa

It has been verified with Spring Boot 4.0.2 and Spring Security 7.0.2.

To understand Spring Security’s MFA support, we recommend reading the following blog posts and documentation first:

Sample App Walkthrough

First, walk through the sample app. It can be run with the following commands (Docker required).

git clone https://github.com/making/demo-2fa
cd demo-2fa
./mvnw spring-boot:test-run

Visit http://localhost:8080/signup and register an account.

image

2FA is disabled by default.

image

Log out.

image

Log in again.

image

Since 2FA is disabled, login succeeds with just username and password.

image

Enable 2FA.

image

Scan the QR code with Google Authenticator.

pasted-image.png

Check the code.

pasted-image.png

Enter the code and press the verify button.

image

2FA is now enabled.

image

Log out.

image

Log in again.

image

This time 2FA is enabled, so you are prompted for a code.

image

Check the code with Google Authenticator.

pasted-image.png

Enter the code and press verify.

image

Login succeeds.

image

Implementation Details

The SecurityFilterChain definition looks like this.

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) {
	var mfa = new DefaultAuthorizationManagerFactory<>();
	mfa.setAdditionalAuthorization(new TotpTwoFactorAuthorizationManager());
	return http.authorizeHttpRequests(authorize -> authorize
		.requestMatchers("/error", "/signup", "/logout", "/*.css").permitAll()
		.requestMatchers("/challenge/totp", "/enable-2fa").hasAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY)
		.anyRequest().access(mfa.authenticated())
	)
		.formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/", true).permitAll())
		.exceptionHandling(exceptions -> exceptions.defaultDeniedHandlerForMissingAuthority(
				(request, response, exception) -> response.sendRedirect("/challenge/totp"),
				TotpFactor.TOTP_AUTHORITY))
		.securityContext(securityContext -> securityContext.requireExplicitSave(false))
		.build();
}

Key points:

  • Before TOTP authentication (e.g., /challenge/totp), FactorGrantedAuthority.PASSWORD_AUTHORITY (i.e., "FACTOR_PASSWORD") is required, meaning password authentication is needed.
  • Any request requires authorization by TotpTwoFactorAuthorizationManager. This manager demands "FACTOR_PASSWORD" when 2FA is disabled, and both "FACTOR_PASSWORD" and "FACTOR_TOTP" when 2FA is enabled.
  • With defaultDeniedHandlerForMissingAuthority, when TotpFactor.TOTP_AUTHORITY (i.e., "FACTOR_TOTP") is missing, an AuthenticationEntryPoint redirects to /challenge/totp.

The implementation of TotpTwoFactorAuthorizationManager is as follows.

package com.example.totp;

import com.example.account.Account;
import com.example.account.AccountUserDetails;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authorization.AllAuthoritiesAuthorizationManager;
import org.springframework.security.authorization.AuthorityAuthorizationManager;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.authorization.AuthorizationResult;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.FactorGrantedAuthority;

import java.util.function.Supplier;

public class TotpTwoFactorAuthorizationManager implements AuthorizationManager<Object> {

	private final AuthorizationManager<Object> mfa = AllAuthoritiesAuthorizationManager
		.hasAllAuthorities(FactorGrantedAuthority.PASSWORD_AUTHORITY, TotpFactor.TOTP_AUTHORITY);

	private final AuthorizationManager<Object> passwordOnly = AuthorityAuthorizationManager
		.hasAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY);

	private final Logger log = LoggerFactory.getLogger(this.getClass());

	@Override
	public AuthorizationResult authorize(Supplier<? extends @Nullable Authentication> authentication, Object context) {
		log.info("Authorizing {}", authentication.get());
		if (authentication.get() instanceof UsernamePasswordAuthenticationToken upat) {
			if (upat.getPrincipal() instanceof AccountUserDetails accountUserDetails) {
				Account account = accountUserDetails.getAccount();
				log.info("username={} 2FA={}", account.username(), account.twoFactorEnabled() ? "enabled" : "disabled");
				if (account.twoFactorEnabled()) {
					return this.mfa.authorize(authentication, context);
				}
				else {
					return this.passwordOnly.authorize(authentication, context);
				}
			}
		}
		log.warn("Authentication is not of expected type");
		return new AuthorizationDecision(false);
	}

}

In the previous article, both password authentication and TOTP authentication had to succeed before the user was considered “authenticated”. Consequently, the presence of 2FA was determined inside an AuthenticationSuccessHandler, i.e., partway through the authentication process.

With Spring Security 7’s MFA:

  • Successful password authentication grants the built‑in FACTOR_PASSWORD authority.
  • Successful TOTP authentication grants a custom FACTOR_TOTP authority.

Thus each factor can be treated independently, and the addition of the defaultDeniedHandlerForMissingAuthority method lets us specify an AuthenticationEntryPoint for each missing authority, simplifying the implementation. An AuthenticationSuccessHandler is no longer needed.

Because of this, the flow after logging out with 2FA enabled works as follows:

  1. Password authentication at the login form grants FACTOR_PASSWORD.
  2. TotpTwoFactorAuthorizationManager sees that FACTOR_TOTP is missing and redirects to /challenge/totp.
  3. TOTP authentication grants FACTOR_TOTP.
  4. Authorization succeeds.

FACTOR_TOTP is the custom authority added in this article, so you must implement the granting of this authority when TOTP authentication succeeds.

The controller for /challenge/totp looks like this.

@PostMapping(path = "/challenge/totp")
public void processTotp(@RequestParam String code, HttpServletRequest request, HttpServletResponse response,
        @AuthenticationPrincipal AccountUserDetails principal, Authentication authentication)
        throws ServletException, IOException {
    Account account = principal.getAccount();
    if (this.codeVerifier.verify(account, code)) {
        Authentication token = authentication.toBuilder()
            .principal(principal)
            .authorities(
                    // ⭐️ Grant "FACTOR_TOTP"
                    authorities -> authorities.add(FactorGrantedAuthority.fromAuthority(TotpFactor.TOTP_AUTHORITY)))
            .build();
        SecurityContextHolder.getContext().setAuthentication(token);
        this.successHandler.onAuthenticationSuccess(request, response, token);
    }
    else {
        this.failureHandler.onAuthenticationFailure(request, response, new BadCredentialsException("Invalid code"));
    }
}

Please refer to the source code for additional details.


This article demonstrated how to implement TOTP as a custom Factor in Spring Security 7’s MFA support and build a 2FA flow. If you want to implement MFA, first consider the built‑in Factors before attempting a custom TOTP implementation. We recommend reading the official MFA support documentation thoroughly.