Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Multi-Factor Authentication (MFA)

Implementing a second factor: TOTP enrolment and verification, recovery codes, trusted-device handling, and why WebAuthn is the endpoint worth aiming at.

Advanced6 min readUpdated
On this page

A second factor turns a stolen password from a full compromise into a failed login. The implementation is straightforward; the parts that determine whether people actually use it are enrolment, recovery and how often you ask.

Key Takeaways

  • TOTP is the practical default; WebAuthn is the strongest and worth aiming at.
  • The enrolment flow must verify a code before enabling, or users lock themselves out.
  • Recovery codes are mandatory — without them, a lost phone is a lost account.
  • Trusted devices keep MFA usable, so people leave it on.
  • Only WebAuthn resists phishing, because it binds to the origin.

How TOTP works

Both sides hold a shared secret. Every 30 seconds each computes HMAC-SHA1(secret, currentTimeStep) and truncates it to six digits. No network, no state, no synchronisation beyond the clock.

TotpService.java
@Service
public class TotpService {
 
    private static final int PERIOD_SECONDS = 30;
    private static final int DIGITS = 6;
    // Accept the adjacent windows: phone clocks drift, and a user typing a
    // code as it rolls over should not fail.
    private static final int WINDOW = 1;
 
    public String generateSecret() {
        byte[] bytes = new byte[20];             // 160 bits, the RFC recommendation
        new SecureRandom().nextBytes(bytes);
        return new Base32().encodeToString(bytes).replace("=", "");
    }
 
    public String provisioningUri(String secret, String account, String issuer) {
        return "otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d"
                .formatted(urlEncode(issuer), urlEncode(account), secret,
                           urlEncode(issuer), DIGITS, PERIOD_SECONDS);
    }
 
    public boolean verify(String secret, String code) {
        if (code == null || !code.matches("\\d{" + DIGITS + "}")) return false;
 
        long timeStep = Instant.now().getEpochSecond() / PERIOD_SECONDS;
        for (int offset = -WINDOW; offset <= WINDOW; offset++) {
            // Constant-time comparison: a byte-by-byte early exit leaks how
            // many digits matched.
            if (MessageDigest.isEqual(
                    generate(secret, timeStep + offset).getBytes(UTF_8),
                    code.getBytes(UTF_8))) {
                return true;
            }
        }
        return false;
    }
}

Two gaps remain in that verification, and both get exploited in practice.

A six-digit code is one of a million, but the acceptance window spans three time steps, so any given code stays valid for up to ninety seconds. An attacker who already has the password and finds an unthrottled verify endpoint can simply guess. Rate limit it hard — five attempts per user per fifteen minutes, counted server-side, with the same progressive delay you would apply to password login.

The second is replay. Because a code remains valid across its whole window, anyone who observes one — over the shoulder, through a phishing proxy, in a log line — can reuse it seconds later. Record the last time step accepted for each user and reject anything at or below it. That is one extra column, and it closes the window completely.

The secret itself is a credential worth as much as a password hash: whoever holds it can generate valid codes indefinitely. That encryptor.encrypt call is doing real work — the secret must be encrypted at rest under a key held outside the database, so a dumped table does not hand over everybody's second factor along with it.

Enrolment

MFA activates only after a successful verification. Enabling it before proving the app works is how users lock themselves out.
MfaEnrolmentService.java
@Service
public class MfaEnrolmentService {
 
    @Transactional
    public EnrolmentChallenge begin(String userId) {
        User user = users.findById(userId).orElseThrow();
        String secret = totp.generateSecret();
 
        // PENDING. Storing it active before verification means a user who
        // mis-scans the QR code can never log in again.
        user.setPendingMfaSecret(encryptor.encrypt(secret));
        user.setMfaPendingSince(Instant.now());
 
        return new EnrolmentChallenge(
                totp.provisioningUri(secret, user.email(), "Acme"),
                secret);      // shown as text too, for manual entry
    }
 
    @Transactional
    public List<String> confirm(String userId, String code) {
        User user = users.findById(userId).orElseThrow();
        String secret = encryptor.decrypt(user.pendingMfaSecret());
 
        if (!totp.verify(secret, code)) {
            throw new InvalidMfaCodeException();
        }
 
        user.activateMfa(user.pendingMfaSecret());
 
        // Generated once, hashed before storage, shown once. There is no way
        // to recover them later — that is the point.
        List<String> recoveryCodes = generateRecoveryCodes();
        user.setRecoveryCodes(recoveryCodes.stream()
                .map(passwordEncoder::encode)
                .collect(toSet()));
 
        auditLog.record("mfa.enabled", userId);
        return recoveryCodes;
    }
}

Show the secret as text alongside the QR code. Users on desktop-only password managers, or with a camera that will not focus, need it — and without it they simply give up on MFA.

Second-factor authentication flow

MfaAuthenticationFilter.java
@Component
public class MfaAuthenticationFilter extends OncePerRequestFilter {
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
 
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
 
        if (auth instanceof MfaPendingAuthentication pending
                && !request.getRequestURI().startsWith("/mfa/")) {
            // First factor passed, second not yet. The user holds a
            // half-authenticated token that grants access to nothing except
            // the MFA endpoints.
            response.sendRedirect("/mfa/verify");
            return;
        }
        chain.doFilter(request, response);
    }
}

The intermediate state is the part to get right. After the password succeeds the user must not hold a fully authenticated session — only a token whose sole permitted action is completing the second factor, with a short expiry.

Recovery codes

RecoveryCodes.java
private List<String> generateRecoveryCodes() {
    var random = new SecureRandom();
    return IntStream.range(0, 10)
            .mapToObj(i -> {
                byte[] bytes = new byte[5];
                random.nextBytes(bytes);
                // Grouped for readability: users transcribe these by hand.
                String raw = new Base32().encodeToString(bytes).replace("=", "");
                return raw.substring(0, 4) + "-" + raw.substring(4);
            })
            .toList();
}
 
@Transactional
public boolean consumeRecoveryCode(String userId, String submitted) {
    User user = users.findById(userId).orElseThrow();
 
    Optional<String> match = user.recoveryCodes().stream()
            .filter(hash -> passwordEncoder.matches(submitted, hash))
            .findFirst();
 
    if (match.isEmpty()) return false;
 
    user.recoveryCodes().remove(match.get());   // single use
    auditLog.record("mfa.recovery_code_used", userId);
    notifications.send(user, "A recovery code was used on your account");
 
    if (user.recoveryCodes().size() <= 2) {
        notifications.send(user, "You have %d recovery codes left"
                .formatted(user.recoveryCodes().size()));
    }
    return true;
}

Notify on every recovery code use. If the user did not do it, that message is how they find out — and it is one of the highest-value security notifications you can send.

Trusted devices

TrustedDeviceService.java
public void rememberDevice(HttpServletResponse response, String userId) {
    String token = UUID.randomUUID().toString();
 
    // Store the HASH, exactly as with any other credential.
    trustedDevices.save(new TrustedDevice(
            userId, passwordEncoder.encode(token),
            Instant.now().plus(Duration.ofDays(30))));
 
    ResponseCookie cookie = ResponseCookie.from("device_trust", token)
            .httpOnly(true).secure(true).sameSite("Strict")
            .path("/").maxAge(Duration.ofDays(30))
            .build();
    response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}

Give users a page listing their trusted devices with a revoke button, and revoke all of them on a password change. A remembered device is a standing MFA bypass, so it needs to be visible and revocable.

Re-prompt regardless of trust before sensitive actions — changing the password, adding a payment method, disabling MFA itself.

WebAuthn

TOTP resists password theft but not phishing: a convincing fake login page collects the password and the code together, and replays both within thirty seconds.

WebAuthn does not have this problem. The authenticator signs a challenge that includes the origin, so a signature produced for evil-acme.com is invalid at acme.com. Phishing stops working structurally rather than through user vigilance.

WebAuthnRegistration.java
// Spring Security 6.4+ has first-class WebAuthn support.
http.webAuthn(webAuthn -> webAuthn
        .rpName("Acme")
        .rpId("acme.com")                    // must match the origin
        .allowedOrigins("https://acme.com"));

Offer WebAuthn first with TOTP as a fallback. Passkeys have removed most of the usability objection — they sync through platform keychains, so losing a device no longer means losing the credential.

What to take away

Verify a code before activating MFA, and issue recovery codes at the same moment. Hash both the codes and device-trust tokens. Remember trusted devices so the friction stays proportional, but re-prompt before sensitive actions and give users a revocation page. And move toward WebAuthn — it is the only common factor that resists phishing.

Frequently Asked Questions

Is SMS an acceptable second factor?
It is better than nothing and the weakest option in common use. SIM swapping, SS7 interception and carrier social engineering all defeat it, and it has been used in real account takeovers repeatedly. Offer TOTP or WebAuthn as the default and SMS only as a fallback for users who cannot use either.
How many recovery codes should I issue?
Eight to ten, each single-use, hashed like passwords, shown exactly once at enrolment. Fewer and users run out; more and they stop treating them as valuable. Regenerate the whole set when one is used, and tell the user how many remain.
Should MFA be required on every login?
That drives users to disable it. Remember trusted devices for thirty days with a signed cookie, and re-prompt on a new device, a new location, or before a sensitive action. The goal is friction proportional to risk, not friction everywhere.

Related tutorials