Complete Java TOTP 2FA Implementation: QR Binding, Replay Protection & Recovery Codes
This article details a production-ready Java implementation of TOTP two-factor authentication covering QR code binding, secret encryption, time-drift handling, replay attack prevention via atomic step consumption, rate limiting, recovery codes with hashed storage, and secure 2FA disable flows requiring re-authentication.
Why TOTP Over SMS
The team needed a second factor for admins, finance staff, and high-risk accounts. SMS was considered but rejected due to carrier dependency, latency, cost, phone-number churn, and user familiarity with authenticator apps like Google Authenticator and Microsoft Authenticator.
What TOTP Actually Verifies
TOTP (Time-Based One-Time Password, RFC 6238) is not a server-generated code sent to the user. Instead, server and client share a Secret and independently compute the OTP using HMAC over the current time step. The core formula: Secret + current time window → HMAC → truncation → 6-digit OTP. The default time step is 30 seconds. Example: Unix time 1793527200 / 30 = counter 59784240. Both sides compute the same code without network transmission.
Binding Flow: Setup Then Confirm
First, a binding session is created via POST /api/security/totp/setup after primary authentication (password, Passkey, etc.). The server generates a 20-byte cryptographically random Secret using SecureRandom, Base32-encodes it (e.g., JBSWY3DPEHPK3PXP...), and returns a QR code containing an otpauth:// URI with issuer, account name, secret, digits=6, period=30. The QR code itself is a sensitive credential — never log it or embed the secret in analytics.
Critical mistake avoided: marking TOTP enabled immediately after Secret generation. Instead, a three-state machine is used: DISABLED → PENDING → ENABLED. After scanning, the user must submit a current 6-digit code to POST /api/security/totp/confirm. Only after successful verification does the state transition to ENABLED, proving the user's device actually holds the Secret.
Secret Storage: Encryption, Not Hashing
Unlike passwords, TOTP Secrets must be recoverable because the server needs the raw Secret to recompute OTPs on every verification. Storing only SHA-256(secret) would break verification. The solution: encrypt the Secret with a server-side key (preferably via KMS/Vault) and store the ciphertext. Schema example:
CREATE TABLE user_totp ( user_id BIGINT PRIMARY KEY, secret_ciphertext TEXT NOT NULL, status VARCHAR(20) NOT NULL, last_used_step BIGINT, created_at TIMESTAMP NOT NULL, enabled_at TIMESTAMP );On verification, decrypt, compute, compare. This prevents database leakage from exposing all Secrets.
Handling Clock Drift: Adjacent Time Steps
A code generated at 10:00:29 may be submitted at 10:00:31 when the server has moved to the next 30-second window. RFC 6238 recommends checking the current step ±1. Verification pseudocode:
public VerificationResult verify(String secret, String code, Instant now) { long currentStep = now.getEpochSecond() / 30; for (long step = currentStep - 1; step <= currentStep + 1; step++) { String expected = generateTotp(secret, step); if (secureEquals(expected, code)) { return VerificationResult.success(step); } } return VerificationResult.failed(); }Do not widen the window to ±5 minutes — that expands the attack surface.
Replay Protection: Atomic Step Consumption
A valid code remains constant for the entire 30-second window. Without replay protection, an attacker could reuse a captured code within the remaining seconds. RFC 6238 mandates that a successfully verified OTP must not be accepted again. The server stores last_used_step and updates it atomically:
UPDATE user_totp SET last_used_step = 59784213 WHERE user_id = ? AND (last_used_step IS NULL OR last_used_step < 59784213);Only if affected rows = 1 is verification successful. A naive check-then-set in application code fails under concurrency: two simultaneous requests could both read the same last_used_step and both pass. The atomic conditional update (or Redis atomic operation) is essential — same pattern as refresh-token rotation.
Login Flow: Challenge Token Before Session
When TOTP is enabled, password verification does not immediately create a session. Instead, the server returns a short-lived secondFactorToken (e.g., 5-minute JWT) proving only the first factor passed. The frontend then calls POST /api/auth/2fa/totp with the challenge token and the 6-digit code. Only after verifying the challenge token validity, TOTP correctness, and unused step is a real LoginSession with access/refresh tokens created.
Challenge Token Design
The challenge token is stored in Redis ( auth:2fa:challenge:{id}) with fields: challengeId, userId, authMethod, createdAt, attemptCount. It expires in 5 minutes, is single-use (deleted on success), and tracks failed attempts (max 5) to prevent brute-forcing the 6-digit space (1,000,000 combinations). Rate limits apply per user, per challenge, and per IP.
Recovery Codes for Lost Devices
If a user loses their phone, they cannot generate TOTP codes. At TOTP enablement, 8–10 recovery codes are generated (e.g., G7P4-KM82), shown once, and stored as password-style hashes (bcrypt/Argon2), not encrypted, because verification only needs equality check, not regeneration. Schema:
CREATE TABLE user_recovery_code ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, code_hash VARCHAR(255) NOT NULL, used BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP NOT NULL, used_at TIMESTAMP );On use ( POST /api/auth/2fa/recovery), the server finds unused codes for the user, compares hashes with constant-time equality, and atomically marks one used:
UPDATE user_recovery_code SET used=true, used_at=NOW() WHERE id=? AND used=false;Only affected rows = 1 succeeds. User is notified of remaining codes. Exhausted codes can be regenerated after strong re-authentication, invalidating all old ones.
Disabling 2FA Requires Re-Authentication
Closing 2FA is a high-risk operation. An attacker with a stolen session would immediately disable 2FA. Therefore, disabling requires a recent strong authentication: if the current session completed TOTP within 5 minutes, allow; otherwise require a fresh TOTP code or Passkey. On disable: delete/invalidate Secret, revoke all recovery codes, log security audit, and optionally notify the user (especially for admin accounts).
Password Changes Do Not Reset TOTP
Password and TOTP are independent factors. User-initiated password change retains TOTP and revokes old sessions. Only admin-driven account recovery may reset TOTP per security policy. Do not treat password, TOTP, Passkey, and refresh tokens as a single "credential" blob — their lifecycles and risk profiles differ.
Coexistence with Passkey: Step-Up Authentication
Passkey (device possession + biometric/PIN) may satisfy strong authentication for regular login. For high-risk operations (transfers, config changes, production deletions), require additional TOTP — step-up authentication. Session tracks authLevel (1 = single factor, 2 = TOTP verified) and strongAuthenticatedAt. Sensitive endpoints check
if (session.authLevel() < 2 || strongAuthExpired(session)) throw StepUpRequiredException();Clock Synchronization Is Non-Negotiable
TOTP depends on Unix time. A server 2 minutes behind standard time will reject all valid codes. Production servers must run NTP/time-sync; monitor clock drift rather than waiting for user complaints. Do not compensate by widening the verification window — fix the infrastructure.
Multi-Instance Deployment
TOTP is naturally stateless across instances: verification only needs Secret, current time, and last_used_step. Secret lives in encrypted DB column; challenge and binding state in Redis. The replay-protection state ( last_used_step) must be in a shared data source (DB or Redis), not JVM memory, otherwise a code consumed on app-1 could be replayed on app-2.
Final Data Model
Core tables: sys_user, user_totp (secret_ciphertext, status, last_used_step, enabled_at), user_recovery_code (code_hash, used, used_at), login_session (session_id, user_id, auth_method, auth_level, strong_authenticated_at). Redis holds short-lived: 2FA challenge, binding challenge, rate-limit counters. Each data type has a clear lifecycle: Secret = long-term secret; recovery code = one-time long-term backup; challenge = few-minute one-time state; session = single-login lifecycle.
Complete Login Flow Summary
No TOTP: Password correct → create LoginSession → issue tokens.
TOTP enabled: Password correct → create SecondFactorChallenge → user submits TOTP → compute current/adjacent steps → verify match → atomically consume last_used_step → invalidate challenge → create LoginSession → issue tokens.
Lost device: First factor passes → submit recovery code → verify hash → atomically mark used → create LoginSession.
Disable TOTP: Current session → re-verify with TOTP/Passkey → delete Secret → revoke recovery codes → audit log.
The article emphasizes that boolean verify(String code) is the trivial part. The real work is the lifecycle: binding confirmation, Secret protection, clock drift, replay prevention, rate limiting, recovery codes, disable re-auth, account recovery. Without these, the system merely "calculates 6-digit codes" — not true two-factor authentication.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
LuTiao Programming
LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
