Multi-Factor Authentication
Factors
| Factor | Examples | Strength |
|---|---|---|
| Something you know | password, PIN | phishable, reused across sites |
| Something you have | phone with an authenticator app (TOTP), SMS OTP, hardware key | TOTP good; SMS weaker (SIM swap) |
| Something you are | fingerprint, face (usually unlocks a passkey on the device) | strong, stays on the device |
MFA means requiring two different factors. The most common setup for admin panels is password + TOTP (Google Authenticator, Microsoft Authenticator). Passkeys (WebAuthn) are phishing-resistant and replace both.
How TOTP Works
Server and app share a secret (scanned once from a QR code). Every 30 seconds both compute HMAC-SHA1(secret, floor(time / 30)) and cut it to 6 digits (RFC 6238). The server accepts the current step and one step either side for clock drift:
Example
The first two lines reproduce the official RFC 6238 test vectors, which proves the algorithm is right:
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public class Main {
static String totp(byte[] secret, long epochSeconds, int digits) throws Exception {
long counter = epochSeconds / 30; // 30-second time step
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(secret, "HmacSHA1"));
byte[] hash = mac.doFinal(ByteBuffer.allocate(8).putLong(counter).array());
int offset = hash[hash.length - 1] & 0x0f; // dynamic truncation
int binary = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16)
| ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff);
int mod = (int) Math.pow(10, digits);
return String.format("%0" + digits + "d", binary % mod);
}
static boolean verify(byte[] secret, String code, long now) throws Exception {
for (int step = -1; step <= 1; step++) { // allow +-30 s clock drift
if (totp(secret, now + step * 30L, 6).equals(code)) return true;
}
return false;
}
public static void main(String[] args) throws Exception {
byte[] secret = "12345678901234567890".getBytes(StandardCharsets.US_ASCII);
System.out.println("RFC 6238, T=59 : " + totp(secret, 59, 8) + " (expected 94287082)");
System.out.println("RFC 6238, T=1111111109 : " + totp(secret, 1111111109L, 8) + " (expected 07081804)");
long loginTime = 1772359200L; // 2026-03-01T10:00:00Z
String code = totp(secret, loginTime, 6);
System.out.println("code shown in the app : " + code);
System.out.println("typed 20 s later : " + verify(secret, code, loginTime + 20));
System.out.println("typed 2 minutes later : " + verify(secret, code, loginTime + 120));
System.out.println("wrong code : " + verify(secret, "000000", loginTime));
}
}Output
RFC 6238, T=59 : 94287082 (expected 94287082) RFC 6238, T=1111111109 : 07081804 (expected 07081804) code shown in the app : 589191 typed 20 s later : true typed 2 minutes later : false wrong code : false
Two-Step Login in Spring Security 6
Spring Security 6 has no single "MFA" switch, so a common pattern for token APIs is two steps: the password step returns a short-lived MFA ticket that can do nothing except finish login; the TOTP step exchanges it for a real access token.
@PostMapping("/api/auth/login")
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest req) {
Authentication auth = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(req.email(), req.password()));
AppUser user = users.findByEmail(auth.getName()).orElseThrow();
if (user.isMfaEnabled()) {
String ticket = jwtService.createMfaTicket(user.getEmail(), Duration.ofMinutes(5)); // scope "mfa" only
return ResponseEntity.ok(Map.of("mfaRequired", true, "mfaTicket", ticket));
}
return ResponseEntity.ok(new TokenResponse(jwtService.createToken(auth), "Bearer", 900));
}
@PostMapping("/api/auth/mfa")
public TokenResponse verifyMfa(@Valid @RequestBody MfaRequest req) {
String email = jwtService.parseMfaTicket(req.mfaTicket()); // throws if expired / wrong type
AppUser user = users.findByEmail(email).orElseThrow();
if (!totpService.verify(user.getMfaSecret(), req.code())) {
throw new BadCredentialsException("Invalid authentication code");
}
return new TokenResponse(jwtService.createToken(user.toAuthentication()), "Bearer", 900);
}Two-step login
curl -s -X POST localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"Correct-Horse-42"}'
curl -s -X POST localhost:8080/api/auth/mfa -H "Content-Type: application/json" -d '{"mfaTicket":"eyJhbGciOiJIUzI1NiJ9.eyJzY29wZSI6Im1mYSJ9...","code":"287082"}'Example output
{"mfaRequired":true,"mfaTicket":"eyJhbGciOiJIUzI1NiJ9.eyJzY29wZSI6Im1mYSJ9..."}
{"accessToken":"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtZWVyYUBzaG9wZWFzZS5pbiIsInJvbGVzIjpbIkFETUlOIl19...","tokenType":"Bearer","expiresIn":900}Note: Spring Security 7 (Spring Boot 4) adds built-in multi-factor support, and Spring Security 6.4+ supports passkeys (http.webAuthn(...)) and one-time-token login (http.oneTimeTokenLogin(...)). If you use Keycloak or Auth0, simply turn on MFA there: your Spring app then receives tokens only after both factors.