I was writing a banking API last year when a client asked, “What happens if someone steals the password?” That question stuck with me. Password theft is the root cause of most account takeovers, yet many applications stop at username and password. That is why I decided to build a multi-factor authentication (MFA) system using Spring Security 6 – not just with TOTP, but also with WebAuthn passkeys. These two second factors give users flexibility while raising the bar for attackers.
Let me show you exactly how I did it, from the data model to the authentication flow, with code snippets you can reuse.
The Two‑Factor Problem
Think about how authentication works today. A password is something you know. A TOTP code from an authenticator app is something you have – well, you have the phone that generates the code. WebAuthn goes further: it uses something you are (biometrics) or something you possess (hardware token). Why would you need both? Because relying only on passwords is like locking your front door but leaving the windows open. When a user logs in, the system should demand a second factor before granting full access.
How do you design a flow that does not frustrate users but still blocks attackers? That is the challenge I tackled.
Phase 1 – Primary Authentication
First, the user logs in with their username and password. If they have MFA enabled, I do not give them a full Authentication object. Instead, I store a pending state in the HTTP session and redirect them to the MFA challenge endpoint. This ensures they cannot access protected resources until they complete the second factor.
public class MfaAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) {
// Validate username/password ...
User user = userRepository.findByUsername(username);
if (user.getPreferredMfaMethod() != MfaMethod.NONE) {
// Return a partially authenticated token
return new MfaPendingToken(username, user.getPreferredMfaMethod());
}
return new UsernamePasswordAuthenticationToken(user, null, authorities);
}
}
I register a custom filter that intercepts the /login endpoint and redirects to /mfa/challenge when the token is of type MfaPendingToken.
Phase 2 – TOTP Verification
For users who choose TOTP (Google Authenticator style), I generate a secret during enrollment, show it as a QR code, and then verify codes during login.
Why use TOTP? It is offline, does not depend on a network, and works with any authenticator app. The challenge is storing the secret safely. I use an EncryptedStringConverter to encrypt the secret with AES‑256 before saving it to the database. This way, even a database dump does not expose the key material.
@Convert(converter = EncryptedStringConverter.class)
private String totpSecret;
During verification, I use the dev.samstevens.totp library:
public boolean verifyTotp(String code) {
Totp totp = new Totp(user.getTotpSecret());
return totp.verify(code);
}
I also persist backup codes (hashed) so users can regain access if they lose their phone. But here is a question: how many backup codes should you generate? I settled on eight – enough for a year of emergencies, but not so many that they weaken security.
Phase 2 – WebAuthn / FIDO2 Assertion
WebAuthn is a different beast. Instead of a shared secret, it uses public‑key cryptography. The user registers a passkey (e.g., Touch ID on a Mac, Windows Hello, or a YubiKey) during setup. Later, when logging in, the browser sends a signed assertion. The server validates the signature using the stored public key.
This is more secure than TOTP because the private key never leaves the user’s device. I use the WebAuthn4J Spring Security integration, which handles the heavy lifting of parsing attestation objects.
@PostMapping("/mfa/webauthn/assert")
public ResponseEntity<?> verifyAssertion(@RequestBody AssertionRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();
PublicKeyCredential credential = assertionService.validate(request, username);
// Update signature count to prevent cloned authenticators
WebAuthnCredential stored = credentialRepo.findByCredentialId(credential.getId());
stored.setSignatureCount(credential.getCounter());
credentialRepo.save(stored);
// Issue full authentication
User user = userRepo.findByUsername(username);
UsernamePasswordAuthenticationToken fullAuth =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(fullAuth);
return ResponseEntity.ok(Map.of("status", "verified"));
}
A word of caution: WebAuthn assertions include a counter that increases each time the authenticator is used. You must verify that the new counter is greater than the stored one – otherwise, a cloned device could replay an old signature.
Putting It All Together – The Filter Chain
Spring Security’s filter chain is the backbone. I add a MfaChallengeFilter that checks if the current authentication is pending. If it is, and the request path is not /mfa/*, the filter returns a 403 with a redirect.
public class MfaChallengeFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof MfaPendingToken && !request.getRequestURI().startsWith("/mfa/")) {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.getWriter().write("Complete MFA first.");
return;
}
chain.doFilter(request, response);
}
}
Then I register the TOTP and WebAuthn verification endpoints with their own filters, which issue the full authentication upon success.
Rate Limiting and Brute Force Protection
An MFA system is only as strong as its resistance to brute force. I added a failedMfaAttempts column to the user entity and a temporary lock (3 minutes) after 5 failed attempts.
if (user.getFailedMfaAttempts() >= 5) {
Instant lockTime = user.getMfaLockedUntil();
if (lockTime != null && Instant.now().isBefore(lockTime)) {
throw new MfaLockedException("Too many attempts. Try later.");
}
// Reset after lock expires
}
This prevents attackers from guessing TOTP codes or replaying WebAuthn assertions. Combined with encryption at rest, the system can handle production load.
Testing the Whole Flow
Integration tests are critical. I use Testcontainers to spin up a PostgreSQL database and simulate the full two‑phase login. Here is a snippet that tests TOTP verification:
@Test
void shouldAuthenticateWithValidTotp() {
// Step 1 – login
mockMvc.perform(post("/login")
.param("username", "alice")
.param("password", "password"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/mfa/challenge"));
// Step 2 – verify TOTP
String code = totpGenerator.getCurrentCode(userSecret);
mockMvc.perform(post("/mfa/totp/verify")
.param("code", code)
.sessionAttrs(session))
.andExpect(status().isOk())
.andExpect(request().userPrincipalName("alice"));
}
I also test WebAuthn assertions by mocking the authenticator’s private key. This ensures the full chain works without a real hardware token.
Why This Matters to You
Every day, millions of accounts are compromised because of weak authentication. Adding MFA saves your users from losing access to email, bank accounts, or social media. Yet many developers skip it because they think it is too complex. It is not.
I have walked you through the core components: pending authentication tokens, TOTP secret storage, WebAuthn signature verification, and brute force protection. You can adapt this pattern to any Spring Boot application.
So, what is stopping you from implementing MFA today? If you already have a login flow, you are halfway there. Copy the filters I showed, adjust the endpoints, and your users will thank you.
If you found this article useful, hit the like button, share it with a colleague who still uses only passwords, and leave a comment telling me which second factor you plan to add first – TOTP or WebAuthn? I read every reply.
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