Java

Build Secure MFA in Spring Security with TOTP, WebAuthn, and Backup Codes

Learn how to build production-ready MFA in Spring Security using TOTP, WebAuthn, encrypted secrets, and backup codes. Start securing logins.

Build Secure MFA in Spring Security with TOTP, WebAuthn, and Backup Codes

Let me tell you why I sat down to write this tutorial. After reviewing dozens of existing articles on multi-factor authentication, I kept running into the same problems: either they relied entirely on third‑party services like Auth0 or they were too abstract, leaving you with no real code to run. Worse, many glossed over the security pitfalls that turn a well‑intentioned MFA system into an open door. I wanted to build something from scratch – using Spring Security, TOTP, and WebAuthn – that you can actually deploy to production. But I also wanted to explain why each piece matters, because understanding the “why” saves you from painful debugging later.

Have you ever noticed how many tutorials stop right after creating a QR code for Google Authenticator? They never discuss what happens when the user’s phone is lost, or how to handle replay attacks, or why you should encrypt the TOTP secret at rest. That’s what we’re going to fix here.


I’ll walk you through every layer: the authentication flow state machine, the database schema, the TOTP secret generation with encryption, the WebAuthn registration and assertion, and the custom Spring Security filters that glue it all together. I’ve been in the trenches with this exact system, and I made mistakes that cost me a weekend of debugging. I’ll share those so you don’t have to repeat them.

But before we get to the code, you need to see the big picture. A secure MFA system isn’t just about adding a second factor – it’s about correctly managing three distinct phases: primary authentication (username/password), the challenge for the second factor (TOTP or WebAuthn), and the final elevation to a fully authenticated session. Spring Security’s default flow assumes one shot: you enter credentials and you’re either in or out. To implement MFA, you have to break that assumption.

Let me show you the flow we’ll build. After the primary login succeeds, I set a custom PreMfaAuthentication token in the security context and redirect the user to a challenge page. At that point they are partially authenticated – they can only see the MFA challenge endpoint, nothing else. Then they provide a TOTP code or complete a WebAuthn assertion. If that succeeds, I replace the PreMfaAuthentication with a full UsernamePasswordAuthenticationToken. If they fail, I clear the context and force them back to the login page.

That intermediate state is crucial. Without it, an attacker who steals a password could skip the second factor. I’ve seen production bugs where developers simply set a flag in the HTTP session, but that’s insecure – HttpSession is vulnerable to session fixation. Use the security context properly. Spring Security gives you that power.


Now let’s talk about the database. I use Flyway migrations to create three main tables: app_users, totp_credentials, and backup_codes. The totp_credentials table stores the encrypted secret along with the AES‑256 GCM IV and authentication tag. Never store a raw TOTP secret – that’s as bad as storing a plaintext password. When a user registers a device, I generate a new secret using the java-otp library, then encrypt it with Jasypt (or your own AES utility) before persisting.

Here’s a snippet of the encryption helper I use:

public class EncryptionUtil {
    private static final String ALGORITHM = "AES/GCM/NoPadding";
    private static final int GCM_IV_LENGTH = 12;
    private static final int GCM_TAG_LENGTH = 128;

    public static EncryptedData encrypt(String plainText, SecretKey key) {
        byte[] iv = new byte[GCM_IV_LENGTH];
        SecureRandom.getInstanceStrong().nextBytes(iv);
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
        cipher.init(Cipher.ENCRYPT_MODE, key, spec);
        byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
        return new EncryptedData(cipherText, iv, cipher.getIV()); // tag is appended
    }
}

Why GCM? Because it gives you authenticated encryption – an attacker cannot tamper with the encrypted secret without detection. I learned that lesson the hard way when I used plain AES‑CBC and wondered why verification sometimes failed. The authentication tag catches corruption.

I also generate ten backup codes per user at enrollment time. Each is a hashed SHA‑256 value stored in the backup_codes table. When a user uses one, I mark it as consumed. Backup codes are a lifesaver when someone loses their phone, but they must be single‑use and stored only as hashes. If you store them plaintext, a database breach exposes all backup codes. Trust me, you don’t want that headline.


Now for the WebAuthn part. WebAuthn (also called passkeys) is a different beast than TOTP – it uses public‑key cryptography. The user’s device generates a key pair; the public key is stored on your server, the private key never leaves the device. This makes it phishing‑resistant because the private key is bound to the origin (your website). No code, no PIN – just biometrics or device PIN.

I use the webauthn4j-spring-security-core library. It provides a WebAuthnAuthenticationProvider that handles the ceremony. But I had to adapt it to fit into my multi‑step flow. Here’s what the registration endpoint looks like:

@PostMapping("/mfa/webauthn/register")
public ResponseEntity<RegistrationOptions> beginRegistration(
        @AuthenticationPrincipal PreMfaAuthentication auth) {
    User user = userRepo.findById(auth.getUserId()).orElseThrow();
    RegistrationOptions options = webAuthnManager.generateRegistrationOptions(user);
    // store challenge in session for later verification
    sessionRepo.saveChallenge(user.getId(), options.getChallenge());
    return ResponseEntity.ok(options);
}

The challenge is a random byte array that the server generates. The client (browser) calls navigator.credentials.create() with it, and the authenticator signs it. Later, when the user wants to authenticate with a passkey, we verify that signature against the stored public key. Notice I store the challenge in a separate repository, not in-memory session, because if the user refreshes the page or opens multiple tabs, you need consistent state. I learned that one when a user complained that passkey login didn’t work after a page reload.


How do you chain TOTP and WebAuthn as alternatives? In my filter, I check whether the user has any registered MFA device. If they have a WebAuthn credential, I present the passkey option first – it’s faster. If they only have TOTP, I show the code input. If they have both, I let them choose. The custom MfaAuthenticationProvider does the actual validation:

@Component
public class MfaAuthenticationProvider implements AuthenticationProvider {
    @Override
    public Authentication authenticate(Authentication authentication) 
            throws AuthenticationException {
        PreMfaAuthentication preAuth = (PreMfaAuthentication) authentication;
        String totpCode = authentication.getCredentials().toString();
        
        // Retrieve encrypted secret, decrypt, verify TOTP
        Totp totp = new Totp(decryptedSecret);
        boolean valid = totp.verify(totpCode);
        
        if (!valid) {
            // Check backup codes
            if (backupCodeService.useBackupCode(preAuth.getUserId(), totpCode)) {
                valid = true;
            }
        }
        
        if (valid) {
            return new UsernamePasswordAuthenticationToken(
                preAuth.getPrincipal(), null, preAuth.getAuthorities());
        }
        throw new BadCredentialsException("Invalid MFA code");
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return PreMfaAuthentication.class.isAssignableFrom(authentication);
    }
}

Notice I also check backup codes within the same provider. That keeps the flow unified. And yes, I verify the TOTP with a time window of ±1 step (30 seconds) to handle clock drift. The java-otp library does that automatically if you set the TimeStep and AllowedDrift. I saw a forum post where someone set drift to zero and wondered why codes expired early. Always allow some drift.


You might ask: what about replay attacks? TOTP codes are valid for 30 seconds; an attacker could intercept one and use it before it expires. But with WebAuthn, the assertion contains a counter field that your server increments each time. If you receive a counter value that’s not greater than the stored value, you know someone is replaying an old assertion. I store the counter per credential and verify it in the WebAuthnAuthenticationProvider. That’s a huge advantage over TOTP.

Now let’s talk about user experience. Nobody wants to type a six‑digit code every single time. I allow “remember this device” by issuing a persistent cookie that signals the user has completed MFA from that browser before. The cookie is signed with HMAC so it can’t be forged. If the cookie is valid and the user’s session expires within a set timeframe (say 30 days), they skip the second factor. That’s a common pattern, but you must be careful: the cookie itself becomes a single point of failure. I encrypt the user ID and a timestamp inside the cookie value.

Here’s the filter that checks the cookie:

@Override
protected void doFilterInternal(HttpServletRequest request,
        HttpServletResponse response, FilterChain chain) 
        throws ServletException, IOException {
    if (isUnauthenticatedOrPreAuth(request)) {
        Cookie cookie = CookieUtils.getCookie(request, "mfa_remember");
        if (cookie != null && validateCookie(cookie)) {
            // auto-elevate to fully authenticated
            SecurityContextHolder.getContext().setAuthentication(
                createFullAuthentication(getUserFromCookie(cookie)));
        }
    }
    chain.doFilter(request, response);
}

That filter must run after the UsernamePasswordAuthenticationFilter but before your endpoint handlers. I placed it in the filter chain order using addFilterAfter.


Testing this beast is not trivial. I use Testcontainers to spin up a real PostgreSQL container, install Flyway migrations, and then write integration tests for each phase. For example, I test that after a primary authentication, the user is redirected to /mfa/challenge and cannot access /dashboard until they provide a valid TOTP. I mock the TOTP generator to return a known code, then assert on HTTP status codes and redirect URLs.

Here’s a test snippet using Spring Security Test:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@Testcontainers
class MfaIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");

    @Autowired
    private MockMvc mockMvc;

    @Test
    void mfaChallengeRequiredAfterPrimaryLogin() throws Exception {
        mockMvc.perform(formLogin("/login")
                .user("testuser").password("correctPassword"))
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrl("/mfa/challenge"));

        mockMvc.perform(get("/dashboard")
                .with(csrf()))
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrl("/mfa/challenge"));
    }
}

I cannot stress enough how important it is to test the negative cases: wrong TOTP code, expired code, missing backup code, invalid WebAuthn assertion. Each of those should return a 401 or redirect to the challenge page with an error message. If you don’t test them, your users will.


One final personal note: when I first deployed this system, I forgot to exclude the MFA endpoints from CSRF protection for the WebAuthn calls. WebAuthn uses POST with a JSON body, and Spring Security’s default CSRF filter expects a token for state‑changing requests. My passkey registration kept failing with 403 errors until I added .csrf().ignoringRequestMatchers("/mfa/webauthn/**") in the security config. Always include that exception, or better, use a stateless CSRF token (like a CSRF cookie) that your JavaScript can read.

Now, I encourage you to take this code, run it, break it, and learn from it. MFA is a powerful tool, but only if you build it right. If you found value in this walkthrough, hit the like button, share it with a teammate who’s struggling with security, and leave a comment about your own MFA war story – I’d love to hear how you tackled these same challenges.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

// Similar Posts

Keep Reading