Multi-factor authentication can fail for ordinary reasons: a phone is replaced, a hardware authenticator is lost, or an authenticator application becomes unavailable. Recovery codes give a user a backup path, but that path also becomes part of the authentication system. If a copied recovery code keeps working after it has been used, anyone who obtained the copy may be able to reuse it later.

The useful mental model is therefore simple: a recovery code is a one-time backup authenticator, not a reusable emergency password. The server should accept a valid code once, consume it as part of that successful authentication, and reject the same code afterward.

This article explains the threat that one-time use addresses, how to model recovery-code state, how to store and regenerate codes safely, and what recovery codes do not protect against.

Start with the threat: a copied code can outlive the incident

Suppose an application gives a user eight recovery codes when multi-factor authentication is enabled. The user stores them in a password manager and also prints a copy.

Months later, the user loses a phone and signs in with code number 3. If the application merely checks whether code number 3 belongs to the account, the code may remain valid:

code 3 presented
      |
      v
code matches stored value
      |
      v
authentication succeeds
      |
      v
code 3 still valid

Now imagine that an old photograph, printout, backup, or other copy of the code is exposed. Reuse turns that old copy into continuing authentication authority.

One-time consumption changes the final state:

code 3 presented
      |
      v
code matches an unused value
      |
      v
mark code 3 consumed
      |
      v
authentication succeeds

later use of code 3 -> reject

The control does not make disclosure harmless. Someone who obtains an unused code may still be able to use it before the legitimate user does. One-time use reduces the period after successful use during which another copy remains useful, and it gives the verifier a clear state transition to enforce.

Model each code as authentication state

A recovery-code set should have explicit state rather than being treated as a text list attached to the account.

A small conceptual model is enough:

recovery code record
- account identifier
- verifier value
- status: unused | consumed
- created time
- consumed time, if used
- generation identifier

The exact schema depends on the application. The important properties are that the server can determine whether a code belongs to the account, whether it has already been consumed, and which issued set it belongs to.

The generation identifier represents one issuance of a recovery-code set. It becomes useful when the user asks for replacement codes. Instead of trying to preserve selected old codes indefinitely, the application can invalidate the previous generation and create a new one.

That produces a clean rule:

only unused codes from the current generation are valid

This rule is easier to reason about than allowing several partly active generations with unclear ownership.

Generate codes as secrets, not identifiers

Recovery codes grant authentication authority, so they need to be unpredictable. Generate them with a cryptographically secure random generator provided by the platform. Do not derive them from usernames, timestamps, sequential counters, account IDs, or other values an attacker can predict.

There is no universal code length that is correct for every system. A code must balance guessing resistance with the fact that a person may need to type it manually. Shorter codes are easier to enter but provide fewer possible values, making online guessing controls more important. Higher-entropy codes are harder to guess but can be less convenient to transcribe.

Treat the format separately from the secret value. For example, grouping characters can improve readability without changing the underlying random value:

AB7K-PQ4M-X2RT

That string is only an illustrative format, not a recommendation for a particular entropy level. Choose the actual generation size from the application’s threat model and authentication requirements.

Do not make recovery codes user-chosen. Human choice tends to produce patterns, and a recovery code has no need to be memorable because its purpose is to be stored for exceptional use.

Store verifier values instead of recoverable plaintext when practical

The server needs to verify a submitted recovery code, but it usually does not need to display the same code again after initial issuance. That allows the application to store a derived verifier rather than recoverable plaintext.

Conceptually:

issued code
    |
    v
one-way derivation
    |
    v
stored verifier

At authentication time, the server derives a verifier from the submitted code using the same scheme and compares it with the stored value.

The appropriate derivation depends on code entropy and the system’s cryptographic design. A high-entropy random code can be handled differently from a short human-entered secret. If codes have limited entropy, a database leak can permit offline guessing unless the storage scheme deliberately makes guesses expensive. Use a well-reviewed authentication or cryptographic library and a storage construction appropriate to the code’s entropy rather than inventing a custom hash format.

This storage decision protects against a specific failure mode: disclosure of the authentication database should not automatically reveal every recovery code in directly usable plaintext. It does not protect a code that is stolen from the user’s copy, captured at an endpoint, or exposed before it is transformed for storage.

Consume a code atomically

One-time use creates a concurrency problem. Two requests can present the same valid code almost simultaneously. A weak implementation may perform these operations separately:

1. read code status
2. see "unused"
3. authenticate
4. mark code "consumed"

If two workers both complete step 2 before either reaches step 4, both requests may succeed.

The verification and state transition therefore need an atomic boundary. In plain language, only one request should be able to change a matching code from unused to consumed.

A useful conceptual operation is:

consume code
where account = expected account
  and verifier = submitted-code verifier
  and status = unused
  and generation = current generation

success only if exactly one unused record was consumed

The implementation might use a transaction, conditional update, compare-and-set operation, or another concurrency primitive provided by the datastore. The mechanism varies; the security property does not.

Do not mark the code consumed merely because somebody submitted it. Consumption should follow successful verification of that code for the intended account. Otherwise, an attacker who can submit guesses could invalidate legitimate recovery codes and turn authentication attempts into a denial-of-service mechanism.

Regenerate by replacing authority, not adding more of it

Users need a way to replace recovery codes after loss, suspected exposure, or depletion. Regeneration should be a security-sensitive action because it creates fresh authentication authority.

A clean regeneration flow is:

sufficiently authenticate user
          |
          v
invalidate old recovery-code generation
          |
          v
generate new random codes
          |
          v
store new verifier values
          |
          v
show new codes through protected session

Invalidating the old generation matters. If regeneration simply adds another set, every forgotten copy of every previous set may remain useful.

The authentication required before regeneration should reflect the account’s sensitivity and the reason regeneration is being requested. A user who still has a strong authenticator can normally prove control before replacing backup codes. A user who has lost every authenticator is in a different recovery state and may require a separate account-recovery process.

That distinction prevents a circular design in which possession of a weak session is enough to mint stronger backup credentials.

Do not turn recovery codes into an authentication bypass

Recovery codes are intentionally an alternate route around an unavailable primary authenticator. That makes their surrounding controls important.

Rate-limit failed recovery-code attempts according to the size of the code space and the application’s threat model. A one-time code can still be guessed while it is unused if the verifier permits enough attempts.

Avoid revealing unnecessary account state in unauthenticated responses. A public endpoint generally does not need to tell a caller whether an account has recovery codes, how many remain, or which code index is unused unless the protocol specifically requires that information.

Record security-relevant events such as successful recovery-code use, regeneration, and repeated failures without logging the codes themselves. Notifications can also help a user notice unexpected recovery activity, but notification is detection support rather than a substitute for correct verification.

Most importantly, apply the same post-authentication authorization rules regardless of how the user authenticated. A recovery code should establish only the identity or assurance that the system explicitly assigns to that authenticator. It should not silently grant administrative privileges or skip separate confirmation required for especially sensitive actions.

Understand what one-time recovery codes do not solve

One-time recovery codes reduce replay of codes that have already been successfully used. They do not solve every recovery threat.

They do not protect an unused code stored in an exposed document or compromised password manager. They do not make a phishing page unable to collect a code and attempt to use it immediately. They do not repair a compromised email account if email is also part of the application’s recovery path. They do not establish that the person requesting complete account recovery is the legitimate account owner.

This is why the trust boundary matters. The application controls generation, verification, server-side state, and invalidation. The user controls where the issued copy is stored. Both sides need protection for the mechanism to work as intended.

For higher-risk accounts, recovery codes are stronger when combined with independent controls such as multiple registered authenticators, strong reauthentication before credential changes, security-event notifications, and a deliberately designed process for cases where all authenticators are lost.

Verify the lifecycle, not just the happy path

Testing should prove the security state transitions rather than only checking that one valid code works.

Start with a fresh set. Confirm that an unused valid code authenticates successfully and that the same code fails afterward. Submit the same valid code concurrently and verify that at most one request succeeds. Regenerate the set and confirm that every unused code from the previous generation is rejected. Confirm that invalid submissions do not consume legitimate codes.

Also verify operational behaviour: recovery codes must not appear in application logs, analytics events, error reports, or support tooling. A code that is correctly protected in the authentication database can still leak through observability systems if request bodies or form fields are recorded indiscriminately.

Finally, test recovery when the user has no remaining codes. The product should lead the user into the intended account-recovery path rather than quietly weakening authentication requirements to avoid a support problem.

Conclusion

Recovery codes are easiest to reason about when they are treated as one-time authenticators with an explicit lifecycle. Generate them unpredictably, protect their verifier values, accept only unused codes from the current generation, consume a successful code atomically, and invalidate old generations when replacements are issued.

The central defensive rule is small but important: successful use must remove that code’s future authentication authority. Recovery still needs rate limiting, secure storage, careful regeneration, logging without secrets, and a separate plan for users who lose every authenticator. Those controls keep the backup path from becoming a permanent weaker password attached to an otherwise strong authentication system.