Multi-Factor Authentication (TOTP and WebAuthn)

Why MFA Matters

A password alone is a single point of failure. Phishing, credential stuffing, and password reuse mean that knowing a password is not proof of identity. Multi-factor authentication requires something the user knows (password) and something they have (phone, hardware key) — compromising one factor is no longer sufficient.


TOTP: Time-Based One-Time Passwords

TOTP (RFC 6238) generates a 6-digit code that changes every 30 seconds, derived from a shared secret and the current time. Apps like Google Authenticator, Authy, and 1Password implement TOTP. The server and the app share the same secret — no network call is needed to verify the code.

Dependency

<dependency>
    <groupId>dev.samstevens.totp</groupId>
    <artifactId>totp-spring-boot-starter</artifactId>
    <version>1.7.1</version>
</dependency>

Or use the lighter com.warrenstrange:googleauth:

<dependency>
    <groupId>com.warrenstrange</groupId>
    <artifactId>googleauth</artifactId>
    <version>1.5.0</version>
</dependency>

TOTP Enrollment Flow

User enables MFA in account settings
    → Server generates a random secret (Base32-encoded)
    → Server persists secret (encrypted) for the user
    → Server returns a QR code URL containing the secret
    → User scans QR code with authenticator app
    → User submits the current TOTP code to verify enrollment
    → Server verifies code → marks MFA as enabled

Generating the Secret and QR Code

@Service
public class TotpService {

    private final GoogleAuthenticator gAuth = new GoogleAuthenticator();
    private final UserRepository userRepository;

    public TotpEnrollmentResponse startEnrollment(String username) {
        GoogleAuthenticatorKey credentials = gAuth.createCredentials();
        String secret = credentials.getKey();

        // Store the secret temporarily (or mark as unverified)
        userRepository.storePendingTotpSecret(username, encrypt(secret));

        String qrCodeUrl = GoogleAuthenticatorQRGenerator.getOtpAuthTotpURL(
            "YourAppName",   // issuer
            username,        // account name
            credentials
        );

        return new TotpEnrollmentResponse(secret, qrCodeUrl);
    }

    public boolean verifyAndEnable(String username, int code) {
        String secret = decrypt(userRepository.getPendingTotpSecret(username));

        if (gAuth.authorize(secret, code)) {
            userRepository.enableTotp(username, encrypt(secret));
            return true;
        }
        return false;
    }

    public boolean verify(String username, int code) {
        String secret = decrypt(userRepository.getTotpSecret(username));
        return gAuth.authorize(secret, code);
    }

    private String encrypt(String plaintext) { /* AES-256 encrypt */ return plaintext; }
    private String decrypt(String ciphertext) { /* AES-256 decrypt */ return ciphertext; }
}

Always encrypt the TOTP secret at rest. A stolen database with plaintext TOTP secrets allows attackers to generate valid codes indefinitely.

Enrollment Endpoint

@RestController
@RequestMapping("/account/mfa")
@PreAuthorize("isAuthenticated()")
public class MfaController {

    private final TotpService totpService;

    @PostMapping("/enroll")
    public TotpEnrollmentResponse startEnrollment(Authentication auth) {
        return totpService.startEnrollment(auth.getName());
    }

    @PostMapping("/verify-enrollment")
    public ResponseEntity<Void> verifyEnrollment(
            @RequestBody TotpVerifyRequest request,
            Authentication auth) {

        boolean success = totpService.verifyAndEnable(auth.getName(), request.getCode());
        if (!success) {
            return ResponseEntity.badRequest().build();
        }
        return ResponseEntity.ok().build();
    }
}

TOTP in the Authentication Flow

The challenge: Spring Security’s UsernamePasswordAuthenticationFilter performs authentication in one step. MFA requires a two-step flow — password first, TOTP second.

Step 1: Password Authentication → Partial Authentication

After verifying the password, do not grant full authentication. Instead, mark the user as “password verified” and redirect them to the TOTP challenge:

@Service
public class AuthenticationService {

    private final AuthenticationManager authManager;
    private final TotpService totpService;

    public AuthResult authenticate(LoginRequest request, HttpSession session) {
        // Step 1: verify password
        try {
            Authentication auth = authManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                    request.getUsername(),
                    request.getPassword()
                )
            );

            UserDetails user = (UserDetails) auth.getPrincipal();

            if (isMfaEnabled(user.getUsername())) {
                // Store partial auth in session — not in SecurityContext yet
                session.setAttribute("MFA_PENDING_USER", user.getUsername());
                return AuthResult.MFA_REQUIRED;
            }

            // No MFA — grant full authentication
            SecurityContextHolder.getContext().setAuthentication(auth);
            return AuthResult.SUCCESS;

        } catch (BadCredentialsException e) {
            return AuthResult.INVALID_CREDENTIALS;
        }
    }

    public AuthResult verifyTotp(int code, HttpSession session) {
        String username = (String) session.getAttribute("MFA_PENDING_USER");
        if (username == null) {
            return AuthResult.SESSION_EXPIRED;
        }

        if (!totpService.verify(username, code)) {
            return AuthResult.INVALID_CODE;
        }

        // Full authentication granted
        UserDetails user = userDetailsService.loadUserByUsername(username);
        Authentication auth = new UsernamePasswordAuthenticationToken(
            user, null, user.getAuthorities()
        );
        SecurityContextHolder.getContext().setAuthentication(auth);
        session.removeAttribute("MFA_PENDING_USER");
        return AuthResult.SUCCESS;
    }
}

TOTP Verification Endpoint

@RestController
@RequestMapping("/auth")
public class AuthController {

    private final AuthenticationService authService;

    @PostMapping("/login")
    public ResponseEntity<LoginResponse> login(
            @RequestBody LoginRequest request,
            HttpSession session) {

        AuthResult result = authService.authenticate(request, session);
        return switch (result) {
            case SUCCESS -> ResponseEntity.ok(new LoginResponse("authenticated"));
            case MFA_REQUIRED -> ResponseEntity.ok(new LoginResponse("mfa_required"));
            case INVALID_CREDENTIALS -> ResponseEntity.status(401).build();
            default -> ResponseEntity.status(500).build();
        };
    }

    @PostMapping("/mfa/verify")
    public ResponseEntity<Void> verifyMfa(
            @RequestBody TotpVerifyRequest request,
            HttpSession session) {

        AuthResult result = authService.verifyTotp(request.getCode(), session);
        return switch (result) {
            case SUCCESS -> ResponseEntity.ok().build();
            case INVALID_CODE -> ResponseEntity.status(401).build();
            case SESSION_EXPIRED -> ResponseEntity.status(401).build();
            default -> ResponseEntity.status(500).build();
        };
    }
}

TOTP Recovery Codes

Always generate backup codes at enrollment. These one-time codes let users recover access if they lose their authenticator device:

@Service
public class RecoveryCodeService {

    private final RecoveryCodeRepository repository;

    public List<String> generateRecoveryCodes(String username) {
        List<String> codes = IntStream.range(0, 10)
            .mapToObj(i -> generateCode())
            .collect(Collectors.toList());

        // Store hashed codes — never store plaintext
        List<RecoveryCode> hashed = codes.stream()
            .map(code -> new RecoveryCode(username, bcrypt.encode(code)))
            .collect(Collectors.toList());

        repository.deleteByUsername(username);
        repository.saveAll(hashed);

        return codes;  // return plaintext to user once, never again
    }

    public boolean useRecoveryCode(String username, String code) {
        List<RecoveryCode> stored = repository.findByUsername(username);

        for (RecoveryCode stored : stored) {
            if (bcrypt.matches(code, stored.getHashedCode())) {
                repository.delete(stored);  // one-time use
                return true;
            }
        }
        return false;
    }

    private String generateCode() {
        byte[] bytes = new byte[5];
        new SecureRandom().nextBytes(bytes);
        return HexFormat.of().formatHex(bytes).toUpperCase();
    }
}

WebAuthn / Passkeys

WebAuthn (Web Authentication API) is a W3C standard for strong, phishing-resistant authentication using public-key cryptography. The user authenticates with a device-bound key (Touch ID, Face ID, security key). No shared secret crosses the network.

Spring Security WebAuthn Support

Spring Security 6.4+ includes native WebAuthn support:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-web</artifactId>
</dependency>
<!-- WebAuthn is included in spring-security-web since 6.4 -->

Enabling WebAuthn

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .formLogin(Customizer.withDefaults())
        .webAuthn(webauthn -> webauthn
            .rpName("Your Application Name")
            .rpId("yourapp.com")
            .allowedOrigins("https://yourapp.com")
        );
    return http.build();
}

Spring Security exposes endpoints:

  • POST /webauthn/register/options — registration challenge
  • POST /webauthn/register — complete registration
  • POST /webauthn/authenticate/options — authentication challenge
  • POST /webauthn/authenticate — complete authentication

JavaScript Client

// Registration
async function registerPasskey() {
    // Get challenge from server
    const optionsResponse = await fetch('/webauthn/register/options', {
        method: 'POST',
        headers: { 'X-XSRF-TOKEN': getCsrfToken() }
    });
    const options = await optionsResponse.json();

    // Decode challenge
    options.challenge = base64UrlDecode(options.challenge);
    options.user.id = base64UrlDecode(options.user.id);

    // Create credential
    const credential = await navigator.credentials.create({ publicKey: options });

    // Send to server
    await fetch('/webauthn/register', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-XSRF-TOKEN': getCsrfToken()
        },
        body: JSON.stringify(encodeCredential(credential))
    });
}

// Authentication
async function authenticateWithPasskey() {
    const optionsResponse = await fetch('/webauthn/authenticate/options', {
        method: 'POST',
        headers: { 'X-XSRF-TOKEN': getCsrfToken() }
    });
    const options = await optionsResponse.json();

    options.challenge = base64UrlDecode(options.challenge);

    const assertion = await navigator.credentials.get({ publicKey: options });

    const response = await fetch('/webauthn/authenticate', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-XSRF-TOKEN': getCsrfToken()
        },
        body: JSON.stringify(encodeAssertion(assertion))
    });

    if (response.ok) {
        window.location.href = '/dashboard';
    }
}

Credential Storage

Spring Security’s WebAuthn integration requires a UserCredentialRepository. Implement it backed by your database:

@Component
public class DatabaseCredentialRepository implements UserCredentialRepository {

    private final WebAuthnCredentialRepository repository;

    @Override
    public void save(CredentialRecord credentialRecord) {
        repository.save(toEntity(credentialRecord));
    }

    @Override
    public boolean delete(UUID credentialId) {
        return repository.deleteByCredentialId(credentialId.toString()) > 0;
    }

    @Override
    public Collection<CredentialRecord> findByUserId(Bytes userId) {
        return repository.findByUserId(userId.toBase64UrlString())
            .stream()
            .map(this::toRecord)
            .collect(Collectors.toList());
    }
}

Choosing Between TOTP and WebAuthn

TOTPWebAuthn/Passkeys
Phishing resistanceNo — codes can be relayedYes — key is bound to the origin
Setup complexityLow — scan QR codeMedium — browser/platform support required
User experienceType 6-digit codeBiometric / hardware key tap
RecoveryBackup codesRecovery codes + platform sync
Server complexityLowMedium
Browser supportNot applicableModern browsers (Chrome, Safari, Firefox)

For high-security applications (banking, healthcare, admin access), prefer WebAuthn. For general applications, TOTP is widely understood and sufficient.


Key Takeaways

  • TOTP generates time-based 6-digit codes from a shared secret — verify password first, then TOTP in a two-step flow
  • Encrypt TOTP secrets at rest and generate recovery codes at enrollment (stored as bcrypt hashes)
  • Always rate-limit TOTP verification endpoints — 10 attempts per 30 seconds maximum
  • WebAuthn/Passkeys use device-bound public-key cryptography — phishing-resistant and the future of authentication
  • Spring Security 6.4+ has native WebAuthn support via http.webAuthn()
  • For most applications, TOTP is the right starting point; add WebAuthn for high-value accounts

Next: Testing Spring Security: @WithMockUser, MockMvc, and SecurityMockMvc — write tests that verify your security configuration is actually enforced.