Multi-Factor Authentication

Factors

FactorExamplesStrength
Something you knowpassword, PINphishable, reused across sites
Something you havephone with an authenticator app (TOTP), SMS OTP, hardware keyTOTP good; SMS weaker (SIM swap)
Something you arefingerprint, 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.