Skip to main content

Command Palette

Search for a command to run...

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...

Published
13 min read
How Do You Implement Multi-Factor Authentication (MFA) in Spring Security Without Getting Stuck in Redirect Loops, Broken Sessions, and “Invalid Code” Nightmares
T

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

Most MFA “implementations” fail for boring reasons, not fancy crypto reasons: the app loses the user’s partially-authenticated state between requests, the OTP clock drifts, or the security flow accidentally treats the second factor like a brand-new login. The symptom list looks dramatic—random “invalid code”, infinite redirects back to /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”

A stable MFA design in Spring Security is easiest when you explicitly model two states: (1) the user passed the password check but is not yet fully authenticated (partial auth), and (2) the user passed OTP and is now fully authenticated (full auth). The most common troubleshooting discovery is that people try to “re-use” the normal 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)

In the example below, the password step creates a custom authentication token that represents “MFA required”. Only a small set of endpoints remain accessible in that state (/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

If you like visual anchors while debugging flows, these are useful references: Spring Security Architecture Diagram, TOTP / Authenticator App concept image, and a broader overview diagram of Authentication vs Authorization. Keep them handy; when your flow breaks, it’s usually because a filter or session boundary isn’t doing what you think it’s doing.

2. A concrete Spring Security MFA example in Java

Below is a self-contained style of implementation you can lift into a Spring Boot project. It uses TOTP (the “Google Authenticator / Microsoft Authenticator” style codes). The main idea is: first factor authenticates to a partial token, then OTP upgrades to full authentication.

2.1. Security configuration: allow OTP endpoints during partial auth

The key is to recognize the “MFA required” token and force navigation to the OTP challenge until it is completed. Notice how we allow /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();
}
}

What makes this configuration “MFA-ready” is not the 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

We’ll represent the partial state with a token that returns 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;
}
}

This token is intentionally boring. The power comes from how you route and upgrade it. Troubleshooting gets easier because your logs can literally say: “user=alice state=MFA_REQUIRED” rather than “user=alice authenticated=true but somehow not really.”

2.3. Password step: authenticate and decide whether MFA is required

In real systems, MFA requirement can be policy-driven: enabled for the user, required for admins, required for suspicious IPs, or required when device trust is missing. For clarity, the example below assumes every user must do MFA.

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;
}
}

The “replacement” is the trick people miss: if you simply redirect to /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

After the form login succeeds, we route to /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
}
}

If you’re troubleshooting redirect loops, this endpoint is where you add logs first. If /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

A lot of MFA pain comes from “small” mismatches: base32 decoding, time-step window, or truncation rules. Here is a clean TOTP verifier using standard Java crypto. The secret is assumed to be base32-encoded (as most authenticator apps use).

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;
}
}

The 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

Now we create /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("/");
}
}

The debugging value here is huge: if /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

This is usually time drift, base32 decoding issues, or the wrong secret being loaded for the user. If you store secrets encrypted, confirm you’re decrypting the exact bytes you originally stored; one invisible whitespace or a wrong charset conversion can doom verification. If the issue affects only some users, suspect user enrollment (wrong QR scanned, multiple accounts in authenticator) or a stale secret rotation. If the issue affects only some servers, suspect clock drift or inconsistent secrets across environments.

4.2. Infinite redirect loops back to /login or back to /mfa/challenge

Loops come from state confusion. If your app redirects to /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

If you run multiple instances and rely on HTTP session without sticky sessions or without shared session storage, the request that submits the OTP may land on a different node than the one that stored the partial state. To users it looks random; to you it looks like “it only fails sometimes.” Either enable sticky sessions, or store the security context in a shared store, or use stateless patterns carefully (but note: stateless MFA is trickier because you need a tamper-proof way to represent “password ok, otp pending” without minting a full session).

4.4. CSRF errors on /mfa/verify

If CSRF protection is enabled (it should be for browser flows), your OTP form must include the CSRF token. Many teams create a quick OTP endpoint, forget CSRF, and then “fix” it by disabling CSRF globally—an expensive shortcut that comes back later like a horror movie sequel. The correct fix is to render the token in the form (Thymeleaf makes this easy) or to use a JSON-based MFA verify flow with proper CSRF handling.

4.5. Users get “verified” but still can’t access endpoints

That usually means the upgraded token is missing authorities/roles, or your access rules depend on claims that your full token doesn’t carry. In the sample we created the full token with empty authorities to keep the code small; in a real project you must load roles and attach them at upgrade time. If your authorization uses 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?”

MFA isn’t just verifying codes; it’s also enrollment and recovery. You need a safe flow for enrolling a new authenticator (QR code generation and secret storage), for confirming enrollment (verify one code), and for account recovery (backup codes, helpdesk flow, or identity proofing). Most “MFA troubleshooting” tickets are not from attackers; they’re from legitimate users who lost a device, reinstalled an authenticator app, or enrolled twice and now don’t know which code is “the real one.”

5.2. Observability: logs that tell you why it failed

Add structured logs around these checkpoints: password success (did we mark MFA required?), entry to /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.

5.3. Content cluster connections worth covering next

If you want this topic to rank and also be genuinely useful, the next cluster pieces practically write themselves: WebAuthn/passkeys in Spring Security, backup codes storage strategies, rate limiting OTP attempts, device trust cookies, step-up authentication for sensitive actions, and MFA with stateless JWT APIs. MFA isn’t a single feature; it’s a small security product living inside your app, and it benefits from being treated that way.

6. Closing thoughts

If MFA feels “randomly buggy,” it’s usually because the flow is implicitly defined in your head but not explicitly modeled in code. Once you separate partial authentication from full authentication, your redirects become predictable, your sessions become testable, and “invalid code” becomes a concrete, diagnosable condition rather than a mysterious user complaint.

If you want to ask anything—like how to render the OTP page with Thymeleaf + CSRF properly, how to store TOTP secrets encrypted, or how to support passkeys alongside TOTP—drop your question in the comments below.

Read more at : How Do You Implement Multi-Factor Authentication (MFA) in Spring Security Without Getting Stuck in Redirect Loops, Broken Sessions, and “Invalid Code” Nightmares

More from this blog

T

tuanh.net

540 posts

Are you ready to elevate your Java, OOP, Spring, and DevOps skills? Look no further!