Recovery codes are easy to underestimate. They may appear only when a user loses an authenticator, so they can look like a convenience feature rather than part of the authentication system. In practice, a valid recovery code can grant enough authority to regain account access or bind a new authenticator.

That makes a recovery code an authentication secret. If the application stores it in plaintext, accepts it repeatedly, or allows unlimited guesses, the recovery path can become easier to abuse than the normal sign-in path.

The defensive model is straightforward: generate recovery codes from strong randomness, store only a verifier, make successful use consume the code, and limit verification attempts. This article explains why each property matters, how they work together, and what they do not protect against.

Treat recovery as another way to prove authority

Suppose an account normally requires a password plus a second factor. The user loses access to that second factor, so the service accepts a previously issued recovery code and then allows the user to enroll a replacement.

From the application’s point of view, the recovery code is not merely a ticket to a help page. It is evidence used to authorize a security-sensitive transition:

recovery code
    -> verify evidence
    -> regain recovery authority
    -> bind replacement authenticator

The important question is therefore not, “Is this feature used rarely?” It is, “What can someone do after this value is accepted?”

If acceptance can restore account control, the code deserves protections appropriate to that consequence.

This threat model focuses on three failures:

  • an attacker obtains the application’s recovery-code database and tries to use stored values;
  • an attacker guesses recovery codes through the online verification endpoint;
  • a code that was already used remains valid and can be replayed later.

The controls in this article reduce those risks. They do not protect a recovery code that the user exposes directly to an attacker, and they do not make recovery phishing-resistant. They also do not replace secure enrollment, session protection, authorization, or notifications after account recovery.

Generate a secret that is difficult to guess

A recovery code should come from a cryptographically secure random generator. Do not derive it from a username, timestamp, sequential identifier, ordinary pseudorandom function intended for simulations, or other predictable application data.

For a saved recovery code that may remain valid for a long time, a practical design is to generate at least 128 random bits and encode them in a form the user can store reliably. The encoding does not add entropy; it only represents the random value as text.

For example, the design can be described without depending on a particular programming language:

random_bytes = secure_random(16)      # 16 bytes = 128 bits
recovery_code = encode(random_bytes)

The exact displayed length depends on the encoding. Separators can improve readability, but they should not be mistaken for additional randomness.

Long random codes have an important operational advantage: if the server stores only a cryptographic hash of a sufficiently unpredictable code, stealing that hash does not normally give an attacker a practical search space to enumerate. Shorter human-friendly codes need more care because their possible values may be small enough to search offline after a database leak.

If usability requires short recovery codes, treat that as a deliberate trade-off. Use a password-style salted, deliberately expensive verifier where appropriate, enforce strict online attempt limits, and consider whether several independent recovery steps are justified by the application’s risk. Do not compensate for weak randomness merely by hiding the endpoint.

Store a verifier instead of the usable code

The server usually does not need to recover the original saved recovery code after issuing it. It only needs to answer a yes-or-no question later: does the submitted value match an unused code for this account?

That means the stored record can contain a one-way verifier rather than the plaintext code.

With a high-entropy recovery code, the basic flow is:

issue:
    code = secure_random_code()
    verifier = hash(code)
    store(verifier, status="unused")
    show code to user once

verify:
    submitted_verifier = hash(submitted_code)
    compare submitted_verifier with stored verifier

A cryptographic hash is one-way for this purpose: verification is easy when the candidate code is known, while recovering a sufficiently random original value from its hash should be computationally infeasible under the assumptions of the hash function.

This changes the consequence of a database disclosure. Plaintext storage gives the database reader immediately usable recovery credentials. Hashed storage instead requires them to find an input that matches a stored verifier. When codes have enough unpredictable entropy, exhaustive guessing is not practical.

Hashing does not repair a weak code space. A six-digit value has only one million possibilities regardless of how strong the hash function is. An attacker who steals its hash can try that small space offline. For low-entropy secrets, use a design intended to resist offline guessing, such as a salted password hashing scheme, and still apply online rate limits.

The general rule is: choose the storage method based on the entropy of the secret, not on the label “recovery code.”

Make successful verification consume the code

A recovery code should not behave like a permanent alternate password. Once a code has successfully authorized recovery, accepting the same code again creates unnecessary replay risk.

The state transition should be explicit:

unused -> successfully verified -> consumed

The verification and consumption steps must also be coordinated so that two concurrent requests cannot both observe the same code as unused and both succeed.

A simplified server-side operation is:

begin transaction

record = find_matching_unused_recovery_code(account, submitted_code)
if record does not exist:
    reject

mark record as consumed
perform authorized recovery transition

commit transaction

The exact transaction boundary depends on the application’s data model. The security property is that successful use cannot leave a window in which another request can reuse the same credential.

If the application issues a set of recovery codes, consuming one does not necessarily require invalidating the entire set. That is a product and threat-model decision. However, each individual code should have a clear lifecycle, and users should be able to replace the remaining set if they believe it has been exposed.

For systems that issue a single saved recovery code, successful use can consume it and issue a fresh replacement only after the recovery flow has established the required authority. The replacement must not silently reactivate the old code.

Rate-limit verification even when codes are strong

Strong random codes make guessing unlikely, but the verification endpoint still needs abuse controls.

Rate limiting serves several purposes. It protects deployments that use shorter codes, limits automated probing, reduces unnecessary work, and provides a useful signal for monitoring. The limit should be tied to the account or recovery credential rather than only to a source IP address, because requests can come from many network locations and legitimate users can share addresses.

A useful decision flow is:

recovery attempt
    -> identify recovery context
    -> enforce attempt policy
    -> verify submitted code
    -> on success, consume code
    -> record security event

Do not reset a failed-attempt counter merely because a caller requests another code or changes an easily controlled request attribute. Otherwise the reset mechanism can defeat the limit it is supposed to enforce.

Rate limiting is not a substitute for entropy. A weak secret remains weak if an attacker can obtain its verifier and guess offline. Conversely, high entropy does not make unlimited online attempts a good operational design. These controls address different failure modes.

Keep recovery codes out of logs and routine application data

A recovery code should exist in plaintext only where it is needed: when generated, when delivered to the user, and briefly when the user submits it for verification.

Do not write the plaintext value to application logs, analytics events, error reports, support tickets, or tracing attributes. Those systems often have broader access and longer retention than the authentication datastore.

Be especially careful with request logging. If a recovery code is placed in a URL query string, it can be copied into server logs, browser history, monitoring systems, and intermediary records. Prefer a request body over an authenticated protected connection for manually submitted codes, and configure observability systems to exclude authentication secrets.

The same principle applies to administrative tools. Support staff usually need to know whether recovery is configured, when a code was issued, or whether one was consumed. They normally do not need to see the usable secret itself.

A verifier-only design makes this separation easier because the application cannot casually display the original code after issuance.

Design regeneration as credential rotation

Users need a way to replace recovery codes. They may have lost a printed copy, stored it in the wrong place, or suspect that someone else saw it.

Treat regeneration as credential rotation rather than as a harmless display action:

verify sufficient account authority
    -> generate new recovery code or set
    -> store new verifier(s)
    -> invalidate replaced code(s)
    -> deliver new code(s)
    -> record and notify as appropriate

The order matters. If the application generates a new code but leaves the old one valid indefinitely, regeneration increases the number of credentials that can recover the account. If it invalidates the old code before the new code is successfully delivered, an interrupted operation can leave the user without the recovery method they expected.

The right transaction and delivery strategy depends on the system. The invariant is more important than a particular implementation: the application should know which recovery credentials are valid, should avoid accidental overlap beyond its stated policy, and should provide a clear path when issuance fails.

For high-impact accounts, require recent authentication before regeneration and notify the user through an already established channel after recovery credentials change. These controls help when an attacker has only an existing session but lacks stronger authentication evidence, and they improve the chance that unauthorized changes are noticed.

Verify the security properties, not only the happy path

A recovery feature is easy to test only as “valid code succeeds.” Defensive testing should exercise the states around that success.

Start with a small set of properties:

  1. A newly issued code works under the intended recovery conditions.
  2. An incorrect code does not grant recovery authority.
  3. A successfully used code fails on a second attempt.
  4. Two concurrent submissions of the same one-time code cannot both succeed.
  5. Regeneration invalidates the credentials that policy says it replaces.
  6. Failed attempts eventually trigger the configured throttling behavior.
  7. Plaintext recovery codes do not appear in normal logs, traces, analytics, or administrative views.
  8. A database record contains only the verifier and necessary lifecycle metadata, not the usable saved code.

Also test interruption. What happens if code issuance succeeds but the response is lost? What happens if the recovery transition fails after verification? The system should have an intentional answer rather than leaving credential state ambiguous.

Operational monitoring should focus on events, not secret values. Useful signals include repeated failed recovery attempts, recovery-code regeneration, successful recovery, and changes to authenticators following recovery. Never include the submitted code itself merely to make an event easier to investigate.

Understand the residual risk

A well-designed recovery code remains a powerful bearer secret. Anyone who obtains the user’s valid plaintext code may be able to use it, subject to the rest of the recovery policy. Hashing protects the server-side stored copy; it does not protect a photograph of a printed code, a compromised password manager, or a phishing page that convinces the user to submit the code.

One-time use reduces replay after successful verification, but it does not guarantee that the legitimate user will be the first person to present a stolen code. Rate limiting reduces online guessing but does not distinguish an attacker who already possesses the exact value.

That is why recovery design must match account impact. A simple consumer service may reasonably accept one strong saved recovery code. A system protecting especially sensitive actions may require independent evidence in addition to the code, stronger reauthentication before recovery settings change, or a more controlled recovery process.

The key is to evaluate recovery by the authority it grants, not by how rarely users invoke it.

Conclusion

Recovery codes are part of the authentication boundary. Design them with the same care as other credentials: generate them from strong randomness, store a one-way verifier instead of plaintext, consume them after successful use, rate-limit verification, keep them out of logs, and make regeneration an explicit rotation operation.

These controls do not make recovery immune to phishing or user-side secret theft. They do make several common server-side failures less damaging and give the application a clear credential lifecycle to enforce and test.

A useful final question is simple: if someone copied the recovery-code database today, would those records themselves be usable to take over accounts? A verifier-only, high-entropy, one-time design should make the answer no under that threat model.