Multi-factor authentication can lock out a legitimate user when a phone is lost, an authenticator is reset, or a security key is unavailable. Recovery codes give the user a controlled fallback. The risk is that this fallback can quietly become an easier way into the account than the authentication method it is supposed to recover.
A recovery code is not just a convenience string. It is an authentication secret that may let someone bypass an unavailable factor. If an attacker obtains a valid code and the application accepts it, the application cannot tell that the person presenting it is not the legitimate user.
The defensive model is therefore straightforward: generate recovery codes with enough unpredictability to resist guessing, reveal them only when the user needs to store them, keep only a verifier on the server, accept each code successfully once, and make regeneration revoke the old set. This article explains why those properties matter and how they fit together.
Recovery is another authentication path
Suppose an account normally requires a password and a second factor. The user loses access to that second factor, so the sign-in flow offers a recovery code instead.
Conceptually, the decision becomes:
password valid
+
second factor valid OR recovery code valid
=
authentication succeedsThat OR is the security boundary. An attacker does not need to defeat the strongest branch if a weaker branch reaches the same authenticated state.
This is why recovery deserves the same design attention as normal sign-in. Adding strong MFA while leaving recovery codes guessable, reusable, retrievable from the account, or weakly protected at rest can move the practical attack target to the fallback path.
The threat model here is narrow. We want to reduce account takeover through guessed, stolen-from-storage, replayed, or obsolete recovery codes. These controls do not protect a user who exposes an unused code to an attacker, and they do not repair a compromised device or stolen authenticated session. Recovery codes also are not phishing-resistant simply because the primary authenticator is phishing-resistant; a user can still disclose a code to the wrong party.
Give each code enough unpredictability
A recovery code has to be usable by a person, but it also has to resist online guessing. Sequential values, timestamps, user identifiers, short random numbers, and codes derived predictably from account data are poor choices because an attacker can search a much smaller space than intended.
Generate codes with a cryptographically secure random number generator. The useful property is entropy: uncertainty about the generated value. Formatting can make a random value easier to read, but formatting does not create entropy by itself.
For example, this is a reasonable conceptual shape:
random bytes -> human-readable encoding -> recovery codeThis is not a recommendation to invent a custom encoding scheme. Production systems should use well-tested cryptographic randomness and a representation that preserves the intended randomness while remaining practical to enter or store.
There is a usability trade-off. Longer codes are harder to type, while shorter codes have fewer possible values. If the design uses codes with a smaller search space for usability reasons, strict online attempt limits become more important. Do not compensate for weak randomness merely by hiding the recovery endpoint; endpoints can be discovered, and security should not depend on their obscurity.
Store a verifier, not a retrievable code
The server needs to determine whether a submitted recovery code is valid. It usually does not need to recover the original code after enrollment.
That distinction lets the storage design follow the same useful principle used for other authentication secrets:
user receives: recovery code
server retains: verifier derived from recovery codeWhen the user later submits a code, the server derives the corresponding verifier and compares it with the stored value. A database reader should not be able to simply read the original unused codes from a plaintext column.
The exact verifier construction depends on the code design and the application’s security requirements. High-entropy random recovery codes can be verified with an appropriate one-way cryptographic construction. Lower-entropy secrets need stronger resistance to offline guessing because a database attacker can test candidate values without triggering the application’s online rate limits. The safer engineering decision is to generate recovery codes with substantial randomness in the first place rather than designing around a tiny code space.
Do not log recovery codes. Logging can create extra copies in application logs, tracing systems, support tools, or log exports whose access controls and retention periods differ from the credential store. Log the security event instead: that recovery was attempted, whether it succeeded, which account was involved, and other non-secret context useful for detection.
Make successful use atomic and single-use
A recovery code should stop working after a successful authentication. Otherwise, someone who obtains a code can reuse it later, and a legitimate user may not realize that the secret remains valuable after the first recovery.
The smallest useful state model is:
unused -> accepted -> consumedThe transition from unused to consumed needs to be atomic with the security decision. A naive implementation can create a race:
if code_is_unused(code):
authenticate_user()
mark_code_used(code)If two requests can both observe the code as unused before either marks it consumed, the same nominally one-time code may succeed twice.
A production implementation should make claiming the code a single atomic operation in the system that owns its state. For example, a conditional database update can succeed only when the record is still unused. Only the request that successfully claims the code should continue through the recovery path.
This is a general lesson about one-time credentials: checking and consuming them are one security operation, not two unrelated database steps.
Regeneration must revoke the previous set
Users eventually lose stored recovery codes or suspect that they have been exposed. The application therefore needs a way to generate replacements.
Regeneration should create a new set and invalidate every unused code from the previous set. Keeping both sets active makes an old copy remain an authentication credential after the user believes it has been replaced.
A clean state transition looks like this:
old set: active
|
regenerate
v
old set: revoked
new set: activeProtect regeneration as a sensitive account action. If an authenticated session alone can silently replace recovery credentials, a stolen session may let an attacker establish a fallback that the legitimate user controls poorly or not at all. The appropriate verification depends on the account’s risk and available authenticators, but recent reauthentication or an existing strong factor is a useful control when the threat model justifies it.
Notify the user when recovery credentials are regenerated or used. A notification does not stop misuse, but it can turn an otherwise silent authentication event into something the account owner can investigate. Do not include the recovery secrets themselves in the notification.
Showing codes once is a trust decision
Many applications display recovery codes immediately after enrollment or regeneration and ask the user to store them somewhere durable, such as a password manager or another protected offline location.
After that moment, there is usually little reason for the application to offer a “show my existing recovery codes” feature. Such a feature requires the server either to retain recoverable copies or to place the secrets somewhere else that can reproduce them. It also means that anyone who gains a sufficiently privileged session may be able to extract unused fallback credentials.
A safer lifecycle is:
generate -> display -> user stores -> server keeps verifiersIf the user loses the codes, generate a new set through a protected flow instead of revealing the old set again.
This design shifts some responsibility to the user: if they fail to store the codes and later lose their primary factor, another recovery method may be necessary. That is a real usability cost. The alternative, making high-value recovery secrets continuously retrievable, creates a different security cost. Applications should choose deliberately based on account sensitivity and available recovery channels.
Rate-limit verification without creating a lockout weapon
Randomness reduces the chance that a guess is correct. Online throttling limits how many guesses an attacker can make through the application. Both matter.
Apply attempt controls to recovery-code verification, and make sure they cannot be bypassed simply by distributing attempts across equivalent endpoints. Monitor repeated failures because they can indicate guessing or abuse.
Be careful with permanent account lockout after a small number of failures. If an unauthenticated attacker can trigger that state for a known account, the defense becomes a denial-of-service mechanism. Temporary throttling, increasing delays, risk-aware challenges, or other bounded controls can reduce guessing without handing arbitrary users an easy permanent lockout switch.
The exact thresholds are operational choices. They depend on code entropy, account value, traffic patterns, and the application’s ability to distinguish abusive automation from legitimate recovery mistakes. Test the controls rather than assuming a rate limiter is active because configuration exists somewhere in the stack.
Decide what successful recovery is allowed to do
Accepting a recovery code proves possession of that fallback secret under the assumptions of the recovery design. It does not prove that the user’s device is trustworthy, that the primary factor is still under their control, or that no attacker has copied another unused code.
For a low-risk application, successful recovery may reasonably establish an ordinary authenticated session. For a high-value account, defense in depth may justify temporarily restricting sensitive actions, requiring additional verification before changing authentication factors, or notifying the user through an already trusted channel.
The important point is to define the post-recovery state explicitly. Avoid accidental privilege jumps where a fallback code not only signs the user in but also silently disables MFA, marks a new device as trusted, changes recovery destinations, and grants a long-lived session without separate policy decisions.
Recovery is also a useful point to let the user repair their authentication setup. If the primary factor was genuinely lost, the application needs a controlled path for enrolling a replacement. Treat that replacement as its own sensitive action rather than assuming that every capability should follow automatically from presentation of a recovery code.
Common designs that weaken the fallback
Several implementation shortcuts undermine the properties above.
Storing codes in plaintext makes a read-only database compromise immediately reveal usable fallback credentials. Allowing reuse turns a one-time secret into a long-lived password. Regenerating codes without revoking the old set leaves forgotten credentials active. Logging submitted codes spreads authentication secrets into systems that were not designed to hold them.
Another subtle mistake is validating a code correctly but consuming it too late. If code use is not atomic, concurrent requests can violate the single-use guarantee. Similarly, a strong code format does little if an unthrottled endpoint permits guesses at a rate the design did not assume.
Finally, recovery codes should not be described as equivalent to phishing-resistant authenticators. They solve availability: they give a user a fallback when a primary factor is unavailable. Their security properties are different, so the application should preserve stronger authentication where it matters instead of letting the fallback silently become the normal path.
Test the lifecycle, not just the happy path
A useful security test follows a code from creation to retirement. Confirm that newly generated codes work through the intended recovery path, that a successful use makes the same code fail on the next attempt, and that two concurrent submissions cannot both consume one code.
Then regenerate the set and verify that every old unused code fails while the new set works. Check that application logs, traces, analytics, error reports, and notifications do not contain the submitted secret. Exercise rate limits from the same account through every endpoint that accepts recovery credentials.
Also test authorization around management actions. A session that is not allowed to regenerate recovery credentials should not gain that capability through a secondary API or older client path.
These tests verify the actual security properties. They are more useful than checking only that a “recovery codes” feature exists.
Keep the fallback deliberately narrow
Recovery codes are valuable because authentication systems need a way to handle lost factors without forcing every user into manual support. They work well when treated as credentials with a deliberately small lifecycle: generated unpredictably, shown for storage, verified without retaining plaintext, accepted once, and revoked when replaced.
The next practical step is to draw the recovery-code state machine for your application. Mark where codes are generated, displayed, verified, consumed, regenerated, and logged. For each transition, ask what authority is required and what happens if two requests arrive at the same time. That small exercise often reveals whether the fallback really has the one-time semantics its name suggests.