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 challengePOST /webauthn/register— complete registrationPOST /webauthn/authenticate/options— authentication challengePOST /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
| TOTP | WebAuthn/Passkeys | |
|---|---|---|
| Phishing resistance | No — codes can be relayed | Yes — key is bound to the origin |
| Setup complexity | Low — scan QR code | Medium — browser/platform support required |
| User experience | Type 6-digit code | Biometric / hardware key tap |
| Recovery | Backup codes | Recovery codes + platform sync |
| Server complexity | Low | Medium |
| Browser support | Not applicable | Modern 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.