How Do You Implement Multi-Factor Authentication (MFA) in Spring Security Without Getting Stuck in Redirect Loops, Broken Sessions, and “Invalid Code” Nightmares
Learn how to implement Multi-Factor Authentication (MFA) in Spring Security with a practical troubleshooting guide. This article explains real-world MFA architecture, partial authentication flow, TOTP verification, and common issues such as redir...

I am Tuanh.net. As of 2024, I have accumulated 8 years of experience in backend programming. I am delighted to connect and share my knowledge with everyone.
1. The quiet truth about MFA bugs in Spring Security
/login, CSRF errors on the OTP form, users getting logged out right after entering the correct code—but the root cause is usually that your authentication state machine is under-specified. Spring Security is excellent at guarding doors; MFA is you adding a second door inside the hallway. If you don’t tell Spring which door the user is currently at, Spring does what it always does: sends them back to the entrance.
1.1. A practical mental model: “partial auth” then “full auth”
UsernamePasswordAuthenticationToken for both states, which makes it hard to distinguish “password OK but OTP pending” from “all good.” Once you separate those states, many problems become diagnosable: you can log them, you can redirect based on them, and you can prevent users from accessing protected endpoints while still letting them reach the OTP page.
1.2. What this guide builds (and why it avoids fragile shortcuts)
/mfa/**, logout, static assets). The OTP step then upgrades the user to a normal fully-authenticated token. This structure avoids the classic “OTP page keeps redirecting to login” issue because Spring can recognize that the user is already partially authenticated and should be routed to the second step instead of restarting the entire flow.
1.3. Image references you can keep open while wiring things up
2. A concrete Spring Security MFA example in Java
2.1. Security configuration: allow OTP endpoints during partial auth
/mfa/** while still blocking everything else unless fully authenticated.
package com.example.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/mfa/", "/css/", "/js/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/post-login", true) // decide where to go next
.permitAll()
)
.logout(Customizer.withDefaults());
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception {
return cfg.getAuthenticationManager();
}
}
permitAll() lines by themselves; it’s the fact that we’re going to control what “authenticated” means by using a custom token type and a post-login router. When developers skip the router and try to redirect inside the password provider, they often create brittle chains where exceptions or saved requests cause surprising redirects.
2.2. The two authentication states: MFA-required token vs fully authenticated token
isAuthenticated() == true (so Spring knows the user passed something), but it carries a marker meaning “OTP not done.” If you keep it unauthenticated, Spring will treat the user as anonymous and can bounce them to login again, which is exactly the loop you don’t want.
package com.example.security.mfa;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
public class MfaRequiredAuthenticationToken extends AbstractAuthenticationToken {
private final String username;
public MfaRequiredAuthenticationToken(String username, Collection<!--? extends GrantedAuthority--> authorities) {
super(authorities);
this.username = username;
super.setAuthenticated(true); // password step succeeded
}
@Override
public Object getCredentials() {
return ""; // no credentials stored here
}
@Override
public Object getPrincipal() {
return username;
}
}
2.3. Password step: authenticate and decide whether MFA is required
package com.example.security.mfa;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class PostPasswordAuthService {
public void onPasswordSuccess(Authentication passwordAuth) {
String username = passwordAuth.getName();
// Imagine you checked user settings / risk rules here.
boolean mfaRequired = true;
if (mfaRequired) {
// Replace the normal token with an MFA-required token.
var partial = new MfaRequiredAuthenticationToken(username, List.of());
SecurityContextHolder.getContext().setAuthentication(partial);
} else {
// Keep the fully authenticated password token.
SecurityContextHolder.getContext().setAuthentication(passwordAuth);
}
}
public boolean isMfaRequired(Authentication auth) {
return auth instanceof MfaRequiredAuthenticationToken;
}
}
/mfa/challenge but keep the original password auth, your app might treat the user as fully logged in and let them access protected endpoints without OTP. If you don’t set any auth and rely on session attributes instead, you often break when sessions are lost or when a load balancer sends the second request to a different node. Replacing the token makes the state explicit and portable.
2.4. Post-login router: send the user to OTP when needed
/post-login, inspect the auth state, and send the user to the correct place. This avoids fighting Spring’s default saved-request behavior.
package com.example.web;
import com.example.security.mfa.PostPasswordAuthService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import java.io.IOException;
@Controller
public class PostLoginController {
private final PostPasswordAuthService postPasswordAuthService;
public PostLoginController(PostPasswordAuthService postPasswordAuthService) {
this.postPasswordAuthService = postPasswordAuthService;
}
@GetMapping("/post-login")
public void postLogin(HttpServletResponse response) throws IOException {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (postPasswordAuthService.isMfaRequired(auth)) {
response.sendRedirect("/mfa/challenge");
return;
}
response.sendRedirect("/"); // normal home
}
}
/post-login sees the user as anonymous, your session/cookie/security-context persistence is broken. If it sees the user as fully authenticated when it should be partial, your password success handling is wrong. Either way, you’re no longer guessing.
3. Implementing TOTP correctly (and why “Invalid code” is usually not the user’s fault)
3.1. A minimal TOTP generator/verifier in Java
package com.example.security.totp;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.time.Instant;
public class TotpVerifier {
private static final int TIME_STEP_SECONDS = 30;
private static final int DIGITS = 6;
public boolean verify(String base32Secret, String code, int allowedWindowSteps) {
long now = Instant.now().getEpochSecond();
long currentStep = now / TIME_STEP_SECONDS;
for (int i = -allowedWindowSteps; i <= allowedWindowSteps; i++) {
String candidate = generateTotp(base32Secret, currentStep + i);
if (constantTimeEquals(candidate, code)) return true;
}
return false;
}
public String generateTotp(String base32Secret, long timeStep) {
byte[] key = Base32.decode(base32Secret); // implement or use a small lib
byte[] counter = ByteBuffer.allocate(8).putLong(timeStep).array();
byte[] hmac = hmacSha1(key, counter);
int offset = hmac[hmac.length - 1] & 0x0F;
int binary =
((hmac[offset] & 0x7F) << 24) |
((hmac[offset + 1] & 0xFF) << 16) |
((hmac[offset + 2] & 0xFF) << 8) |
(hmac[offset + 3] & 0xFF);
int otp = binary % (int) Math.pow(10, DIGITS);
return String.format("%0" + DIGITS + "d", otp);
}
private byte[] hmacSha1(byte[] key, byte[] data) {
try {
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(key, "HmacSHA1"));
return mac.doFinal(data);
} catch (Exception e) {
throw new IllegalStateException("HMAC failure", e);
}
}
private boolean constantTimeEquals(String a, String b) {
if (a == null || b == null) return false;
if (a.length() != b.length()) return false;
int r = 0;
for (int i = 0; i < a.length(); i++) r |= a.charAt(i) ^ b.charAt(i);
return r == 0;
}
}
allowedWindowSteps parameter is a real-world lifesaver: setting it to 1 tolerates ±30 seconds, which covers typical phone clock drift. When users swear the code is correct and your system says it’s wrong, it’s very often time drift or an incorrect secret encoding, not user error. If your server runs in containers, check time sync (NTP) first; “Invalid code” caused by a drifting node is the kind of bug that makes you doubt reality.
3.2. OTP challenge controller: verify and upgrade authentication
/mfa/challenge and /mfa/verify. The verify endpoint checks the token state and the OTP, then upgrades to a fully authenticated token.
package com.example.web;
import com.example.security.mfa.MfaRequiredAuthenticationToken;
import com.example.security.totp.TotpVerifier;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
@Controller
@RequestMapping("/mfa")
public class MfaController {
private final TotpVerifier totpVerifier;
public MfaController(TotpVerifier totpVerifier) {
this.totpVerifier = totpVerifier;
}
@GetMapping("/challenge")
@ResponseBody
public String challenge() {
// In a real app, return a proper HTML/Thymeleaf page with CSRF token.
return """
<h2>Enter your 6-digit code</h2>
<form method="post" action="/mfa/verify">
<input name="code">
<button type="submit">Verify</button>
</form>
""";
}
@PostMapping("/verify")
public void verify(@RequestParam("code") String code,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth instanceof MfaRequiredAuthenticationToken)) {
response.sendRedirect("/"); // already verified or not in MFA flow
return;
}
String username = auth.getName();
// Load user's stored secret from DB; hard-coded here for demo.
String base32Secret = "JBSWY3DPEHPK3PXP"; // example only
boolean ok = totpVerifier.verify(base32Secret, code, 1);
if (!ok) {
response.sendRedirect("/mfa/challenge?error=invalid_code");
return;
}
// Upgrade to fully authenticated token (attach roles/authorities here)
var full = new UsernamePasswordAuthenticationToken(username, "", List.of());
SecurityContextHolder.getContext().setAuthentication(full);
response.sendRedirect("/");
}
}
/mfa/verify is reached but auth is null or anonymous, your security context is not being persisted (cookie/session/cluster issue). If it’s a normal password token already, you accidentally let the password step fully authenticate without enforcing MFA. If it’s the expected MfaRequiredAuthenticationToken but verification fails too often, focus on TOTP specifics: secret storage, encoding, time sync, and replay protection.
4. Troubleshooting guide: the problems that actually happen in production
4.1. “Invalid code” even when the code is correct
4.2. Infinite redirect loops back to /login or back to /mfa/challenge
/mfa/challenge but the user is not permitted to access it, Spring will redirect to /login, and after login you redirect again, and congratulations—you invented a treadmill. Another classic loop is when you mark the partial token as unauthenticated; Spring sees the user as anonymous and starts the login flow again. The clean fix is exactly what we did: a distinct partial token that is authenticated enough to access MFA pages but not enough to access protected business endpoints.
4.3. OTP works locally but fails behind a load balancer
4.4. CSRF errors on /mfa/verify
4.5. Users get “verified” but still can’t access endpoints
hasRole("ADMIN"), but your upgraded token has no roles, the user will look logged in yet be blocked everywhere, which feels like being invited to a party and then stopped at every room.
5. Expanding beyond basics: MFA that scales and survives messy real life
5.1. Enrollment, recovery, and “what if the phone is gone?”
5.2. Observability: logs that tell you why it failed
/mfa/challenge (is auth partial?), submit to /mfa/verify (did we still have partial state?), and verification result (time-step delta used, but never log the OTP). When you can see “user state changed from MFA_REQUIRED → FULL” in logs, debugging turns from folklore into engineering.





