The user calls or opens a ticket: "I changed my phone, the two-factor code isn't coming, unlock my account." You're sitting behind the panel and have to decide within minutes. This is the exact moment it becomes clear whether your account recovery path is designed, or is just a "reset by admin" button that anyone with an email can abuse. If that button is unregulated, all the effort you put into 2FA becomes zero; the attacker doesn't need to crack the code, they just need to convince support.
Why the account recovery path is the weak point of 2FA
Two-factor authentication assumes that the second factor (phone, TOTP app, hardware key) is in the user's possession. But every recovery system builds an exception on top of that assumption: "What do we do if the second factor isn't available?" And the attacker works precisely on that exception. In security literature, this attack is called MFA fatigue; that is, instead of cracking the password, they pressure the human in support.
I've seen three failure patterns a lot in practice:
- Reset via backup email: The user registered a recovery email years ago, and that same email has been exposed in data breaches. The attacker uses that email to submit a recovery request, and the code lands in their own inbox.
- Reset via phone number: If you read the number from the user's profile and the number is unverified, the attacker registers their own number and then submits a recovery request.
- Manual reset by admin: The worst case. A tired operator, at 3 AM, with a fake screenshot of the user panel, changes the password.
The point many people miss is this: account recovery is an authentication path, not a support process. It must have logging, rate limiting, and be auditable, just like login. If your audit log only records "successful login" and not "password reset by admin," you're effectively blind. In What is an audit log and how do we keep it intact? I've written in detail about which fields you should record and how to protect the log from operator tampering.
Recovery code: how to store it and how to invalidate it
The recovery code must be stored single-use and hashed, exactly like a password. If you keep the code as plaintext in a recovery_code column, a simple SQL Injection opens up all accounts. The correct pattern is:
-- Suggested schema
CREATE TABLE recovery_codes (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
code_hash CHAR(60) NOT NULL, -- bcrypt
used_at DATETIME NULL,
created_at DATETIME NOT NULL,
INDEX idx_user (user_id)
);
When generating, hash the code with bcrypt or argon2id and show it to the user only once. Limit the number of codes; ten codes is enough. Each code that is used should have used_at populated and should no longer be usable. If the user loses all codes, the path should go toward human identity verification, not automatic reset.
A common trap: hashing the recovery code with md5 or sha1 because "the code is long." The length of the code has nothing to do with the security of the hash. A recovery code is typically 16 characters and its search space is small; without salt and a slow algorithm, it can be cracked with a GPU in a few hours.
This is where they make mistakes
The real mistake I've seen a lot: the security team implements the recovery code correctly, but puts a "forgot password" option on the login form that directly changes the password via email and bypasses 2FA. That means a user who has 2FA enabled can log in without the two-factor code by clicking "forgot password" and having access to email. The sign is this: in the logs, you see users who have 2FA enabled sometimes logged in without a two-factor event being recorded. If you see this, know that your 2FA is decorative.
Support identity verification: what cannot be forged
When the user has closed all paths, the last line of defense is human identity verification. Here you should ask questions that are not in data breaches. Date of birth, national ID number, last four digits of the card, and mother's name have all been seen many times in Iranian breaches and have no probative value.
The questions that actually work come from internal system data that the attacker doesn't have access to:
- The date of the last successful login to the account (with a few days' tolerance acceptable).
- The names of two domains or two projects the user created, without showing them in the panel.
- The amount of the last paid invoice and the payment method.
- A recent event that exists only in the internal log, such as "DNS change on a specific date."
If a user can't answer these, reject the recovery. Yes, a real customer may end up dissatisfied. But the cost of a stolen account is far greater than one dissatisfied ticket. This is where you need to take a stance, not dodge it with "well, let's just unlock it this time."
Rate limiting and gradual lockout
A recovery path without a rate limit is an oracle for password testing. At minimum, enforce these:
| Event | Suggested limit | Response |
|---|---|---|
| Recovery code request | 3 times per 15 minutes | Temporary lock + alert email |
| Wrong code entry | 5 times | Invalidate all codes |
| Reset by admin | At most 2 times per day per operator | Requires second-person approval |
Take the third case seriously. If one operator can reset users' passwords unlimited times, you've built a human backdoor. The four-eyes rule isn't expensive here; it's just one extra approval.
When the user has truly lost access
The hard case: the user has no recovery code, no access to email, and no access to the number. Here you have two choices. First, full recovery with human identity verification and complete logging. Second, rejection and offering to create a new account with data transfer by the user themselves. I choose the first, but only if the audit log is intact and non-editable and two-person approval is mandatory. If those two conditions aren't met, choose the second; the risk is lower.
A practical note: if you provide service on your own infrastructure, part of this comes back to the network layer. Credential stuffing attacks that target the recovery path usually come from a few specific IPs and can be contained with DDoS protection and rate filtering at the network edge. Also, to check where requests are coming from, DNS and network lookup tools help you figure out whether the traffic is coming from a specific ASN or not.
What to change today
If you can only do one thing, it's this: any path that changes the password without the second factor must either be removed or placed behind human identity verification with mandatory logging. After that, hash the recovery codes and add rate limiting. If your team is still working with shared passwords, first read password management in teams; because account recovery only makes sense when passwords are managed correctly from the start.
And if you work on WordPress, remember that the REST API is often an unofficial recovery path. In Securing the WordPress REST API I've explained how to prevent user list leakage, because that same list is the starting point of an attack on the recovery path.
Frequently asked questions
Should the recovery code be hashed or encrypted?
Hashed, and with a slow type like bcrypt or argon2id. Encryption is only needed if you want to show the code to the user again, which you should never do in the case of a recovery code. The code is single-use and after the first display, there's no need to read it again.
How many recovery codes are enough?
Ten codes is enough. More than that enlarges the attack surface without real benefit. More important than the number is that each code be usable only once and that after five wrong attempts, all codes be invalidated.
What should we do if the user has no access to email or phone?
You should use human identity verification with internal questions and record the entire process in the audit log. If you can't do two-person approval, it's better to reject the recovery and tell the user to create a new account and transfer their data.
Can recovery be fully automated?
No, if you want serious 2FA. Any automatic recovery is an exception to 2FA, and the attacker works on exactly that. Automatic recovery is only acceptable when another second factor (such as a backup hardware key) is available.
If you want to make sure the underlying layers of your service are in a stable state, check the real-time service status and for technical details visit the documentation and knowledge base. For teams that want to set up these controls correctly from the start, ServerNet security services are a logical starting point.
Comments 0
No comments yet — be the first!