Complete Java Account Security Recovery: Reclaiming Accounts After Compromise
This article details a comprehensive Java implementation for secure account recovery, covering threat modeling, recovery challenge design, assurance scoring, leveraging pre-compromise credentials like Passkeys and recovery codes, security versioning to invalidate attacker sessions, post-recovery audits, and rate limiting to prevent abuse.
Distinguish "Forgot Password" from "Account Theft Recovery"
The author previously implemented a one-click account freeze that increments securityVersion, invalidating all access tokens, refresh tokens, sessions, and trusted devices. However, freezing is only step one; the real challenge is helping the legitimate user regain control.
Simple password reset via email or SMS works when the user merely forgot the password. In account theft scenarios, the attacker may have already changed the password, phone number, recovery email, and enrolled new MFA or Passkeys. The user's original phone may be lost, TOTP on the old device, and Passkeys only on the old device.
If the backend only offers a single flow:
Forgot Password → SMS Code → Change Passwordthen "account recovery" is extremely fragile because recovery itself is another login system. As the author emphasizes: An account's ultimate security depends not on the strongest login method but on the weakest recovery entry point.
OWASP's current account recovery guidance stresses that when an account is suspected compromised, merely changing the password does not guarantee control restoration because the attacker may retain active sessions or have altered recovery methods and MFA. After recovery, the system should re-verify security settings and invalidate existing sessions and old recovery links.
The first improvement was splitting the flows: Forgot Password (simple reset, assumes other security info is trustworthy) Account Recovery (e.g., POST /api/account-recovery/start) for suspected compromise
After a normal password reset, only the password is changed and old sessions invalidated. After a security recovery, the system must re-check: password, phone, recovery email, Passkey, TOTP, recovery codes, trusted devices, and login sessions. Simply changing the password is insufficient.
Don't Reveal Account Existence to Attackers
The recovery start endpoint accepts an identifier (email, phone, username). Naive code would query the database and throw "account not found" if missing, allowing attackers to enumerate registered accounts.
Instead, the response should be consistent regardless of existence:
{ "message": "If the account exists, we will provide further recovery options" }OWASP requires consistent responses for forgot-password flows, avoiding timing differences that leak account existence, and applying rate limits.
Recovery Challenge Must Not Equal userId
After accepting the identifier, the server creates a RecoveryChallenge record (stored in Redis with a 15-minute TTL) containing a random challengeId, userId, status, assurance score, and timestamps. Only the random challengeId is returned to the client: { "recoveryChallenge": "RC_8cf29e..." } The challenge ID must be a secure random value (32 bytes, URL-safe Base64 without padding), not the userId or a simple encoding. It serves as a security credential. The challenge tracks which verifications passed, failure counts, allowed recovery methods, and expiration.
Don't Trust Recently Changed Recovery Information
If an attacker changed the phone number yesterday, sending an SMS to the current number delivers the code to the attacker. Therefore, recovery must not blindly trust current account data, especially recently modified fields: phone, recovery email, Passkey, MFA.
OWASP advises prioritizing independent recovery evidence established before the suspected compromise. The author adds timestamps to security fields: phone_changed_at, recovery_email_changed_at, mfa_changed_at, passkey_added_at. If the phone was changed 10 minutes ago, it receives low trust.
Avoid Single-Factor "Full Access" Recovery
Early designs often equate one successful SMS verification with full recovery. The author prefers a Recovery Assurance scoring system:
Old recovery code verified +50
Original Passkey verified +60
Original recovery email verified +30
Original phone verified +30
Manual review passed +70Recently added methods score low or zero. Thresholds vary by account sensitivity: regular users need 60, administrators need 100. For example, a regular user verifying original phone (+30) and original recovery email (+30) reaches 60. An admin with the same two factors (60) still needs an additional factor like a recovery code, Passkey, or manual review.
The key principle: High-risk account recovery must not be weaker than normal login. OWASP states account recovery is an authentication method and should not become a weak bypass for strong authentication.
Recovery Codes Finally Prove Their Worth
One-time recovery codes generated during TOTP enrollment (e.g., G7P4-KM82) become critical when phone and Passkey devices are unavailable. The server stores only a hash (via passwordEncoder.encode()). Verification uses POST /api/account-recovery/verify-code and must atomically consume the code:
UPDATE user_recovery_code SET used = true, used_at = NOW() WHERE id = ? AND used = false;Only affected rows = 1 counts as success. Once used for recovery, the code is permanently invalidated. If recovery succeeds, all remaining old recovery codes are invalidated and a new set generated, because a security incident means past long-lived backup credentials should no longer be trusted.
Original Passkeys Are Strong Recovery Evidence
If the user's password and phone are compromised but their Mac retains a Passkey registered before the attack, that Passkey provides strong proof of ownership via WebAuthn challenge → device private key signature → server public key verification. Recovery systems should not treat password, SMS, email, and Passkey as equal; they have different trust levels. For high-risk recovery, prefer pre-attack evidence: old Passkeys and old recovery codes over a phone number added yesterday.
Passkeys Must Be Timestamped
An attacker who gains access may add their own Passkey. If recovery only checks that a Passkey belongs to the userId, the attacker's Passkey would also pass. The Passkey table must store created_at and last_used_at, plus a compromiseSuspectedAt timestamp on the account. If passkey.createdAt > compromiseSuspectedAt, that Passkey should not count as independent recovery evidence and can be flagged SUSPECT pending user confirmation after recovery. This illustrates why timelines are critical: not everything that currently exists is trustworthy; we must know when it was added.
Retain Phone and Recovery Email Change History
Many systems overwrite the phone column directly, losing the old value. For high-quality recovery, maintain a security change log ( user_security_change) recording: userId, field, masked old/new values, changedAt, sessionId, deviceId, riskLevel. Sensitive data can be encrypted, hashed, or masked; the goal is preserving temporal relationships: when was the phone changed? When was the recovery email changed? When was MFA disabled? When was a Passkey added? This lets the system distinguish a two-year-old recovery channel from one added five minutes after the compromise.
Don't Freeze Accounts on Anonymous Requests
If an unauthenticated request with just an email can immediately set securityStatus = USER_FROZEN, attackers can launch denial-of-service by repeatedly freezing arbitrary accounts. The fix: separate challenge creation from freezing. The first step creates a RecoveryChallenge and sends verification without modifying account state. Only after the user proves sufficient ownership, or the system detects severe anomalies, should it invalidate all sessions. OWASP similarly advises against locking or modifying an account solely because a password reset was requested.
Anonymous requests may only initiate recovery, not change security state.
Don't Auto-Login After Recovery
After a high-risk recovery, the author prefers Recovery Complete → Require Re-login with New Credentials over issuing access/refresh tokens directly. The recovery flow is already complex; adding session creation, token issuance, and trusted device establishment makes auditing harder. OWASP's password reset guidance also recommends against auto-login, directing users through the normal authentication flow. The recovery API's sole responsibility is restoring account control; login remains at /auth/login.
On Recovery Success, First Cut Off the Attacker
Many systems just set a new password and stop. But if the attacker holds a refresh token or trusted device token, they retain access. The final recovery operation is designed as a security transaction:
@Transactional
public void completeRecovery(String challengeId, String newPassword) {
RecoveryChallenge challenge = recoveryService.getVerifiedChallenge(challengeId);
User user = userRepository.findByIdForUpdate(challenge.userId()).orElseThrow();
user.changePassword(passwordEncoder.encode(newPassword));
user.incrementSecurityVersion();
user.setSecurityStatus(SecurityStatus.ACTIVE);
recoveryRepository.invalidateOutstandingChallenges(user.getId());
}The critical line is user.incrementSecurityVersion(). Version change invalidates all previous access tokens, refresh tokens, login sessions, and trusted devices, cutting off the attacker completely. This aligns with the earlier one-click freeze mechanism.
Post-Recovery Security Review
After recovery, the attacker may have added their own Passkey, bound their phone, changed the recovery email, added trusted devices, or disabled the original TOTP. The user must review these settings via a Security Review page. The backend provides GET /api/account-recovery/security-review returning masked phone, recovery email, and Passkey list with device names and creation timestamps. If the compromise occurred on Nov 6, a Passkey created that day is highly suspicious. OWASP explicitly recommends that after compromised-account recovery, the verified owner re-checks recovery addresses, phone numbers, and MFA, removing unauthorized changes.
Invalidate All Old Recovery Links
If a user initiates three recovery challenges (R1, R2, R3) and completes via R3, R1 and R2 must also be invalidated; otherwise an attacker with an old link could re-compromise the account. The solution mirrors securityVersion: maintain a recoveryVersion on the user. Each challenge records its version. On success, increment the version (7 → 8). All challenges with the old version automatically expire.
Rate Limit Recovery Attempts
Account recovery is an attack vector. Limits needed per account, IP, device, and challenge. Example: same account max 3 recovery starts per 10 minutes; same IP cannot batch-try hundreds of accounts; 5 consecutive code failures invalidate the challenge; CAPTCHA for anomalous requests. OWASP recommends per-account rate limits and anti-automation protections for password recovery.
Security Questions Are Not Primary Recovery Factors
Questions like "elementary school name" or "mother's name" are guessable from social media, public records, acquaintances, or past breaches. OWASP's MFA guidance states security questions are not qualified independent authentication factors and should not be the sole basis for account reset. Preferred evidence: old Passkeys, recovery codes, long-verified emails/phones, high-assurance manual verification.
Admin Manual Recovery Requires Approval and Audit
For enterprise SaaS or financial systems, users may lose all automated recovery methods. A dangerous implementation is a simple @PostMapping("/admin/reset-user") that clears MFA with one click. Any admin recovery must log: applicant, approver, reason, identity verification artifacts, timestamp, target user, and which auth factors were cleared. High-privilege accounts should require dual approval. All operations must be security-audited because anyone who can recover another's account can take it over — this permission cannot be as casual as editing a profile.
Don't Immediately Create Trusted Devices After Recovery
After recovery and re-login, the UI may prompt "Trust this device for 30 days." The author recommends a conservative window: e.g., no new long-term device trust for 24 hours, or at least require strong authentication first. Account recovery is a high-risk event; OWASP's session management guidance advises re-authentication after recovery, password reset, or suspicious login to prevent weak recovery flows from inheriting high-privilege states. A freshly recovered account should not be treated as a low-risk normal account.
Model Recovery as a State Machine
Instead of scattered booleans ( emailVerified, phoneVerified, etc.), give RecoveryChallenge a clear status enum:
public enum RecoveryStatus {
STARTED,
VERIFYING,
VERIFIED,
SECURITY_REVIEW_REQUIRED,
COMPLETED,
EXPIRED,
CANCELLED
}Flow:
STARTED → choose methods → VERIFYING → collect evidence → VERIFIED → reset password + securityVersion++ → SECURITY_REVIEW_REQUIRED → check phone/email/Passkey/MFA → COMPLETED. Expiry → EXPIRED; anomaly → CANCELLED. Services can enforce transitions:
if (challenge.status() != RecoveryStatus.VERIFIED) throw InvalidRecoveryStateException();— cleaner than checking disparate fields.
The Complete Account Security Loop Is Closed
The series now forms a cohesive system:
Normal login: password, SMS, QR, Passkey.
Post-login: LoginSession manages devices, Access Token for short-lived API access, Refresh Token for silent renewal; TOTP as second factor, Trusted Device reduces repeated verification.
Risk Engine detects unknown device, anomalous location, brute force, impossible travel → demands step-up authentication.
User confirms "This wasn't me" → immediate freeze via securityVersion increment → all old login states invalidated.
Enter Account Recovery: verify ownership → build sufficient assurance → reset credentials → securityVersion rotates again → invalidate all old recovery links → security review of phone/email/Passkey/MFA → re-login via normal /auth/login.
The loop extends from "how to log in" to "how to truly regain control after theft" — a part many login systems lack.
The Real Danger Isn't "User Forgot Password"
Forgotten password is a normal product flow. The real danger: attackers can also click "Forgot Password". Account recovery is not a minor side feature; it is another door into the account. If normal login requires password + TOTP but recovery only needs one SMS code, attackers will target the weaker recovery path. If login uses Passkey but recovery relies on "elementary school name", the Passkey's strength is irrelevant.
When designing a login system, the author now asks not only "Is the login interface secure enough?" but also "When the user loses their phone, forgets their password, and has no old devices, what evidence will we accept to return the account?" The answer to that question determines the true security floor of the entire authentication system. Good recovery gives users a chance to reclaim control in the worst case; poor recovery becomes the attacker's shortest path around passwords, TOTP, Passkeys, and login risk controls.
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.
