Skip to content

Recovery Codes

Recovery codes are a set of one-time-use codes an account can fall back on when its usual factor is unreachable — the phone with the authenticator app is in a river, the passkey’s device is lost, the recovery mailbox is gone. Authkestra implements them as a look-up secret authenticator per NIST SP 800-63B §5.1.2, and that framing is deliberate: this is a fallback for whatever the account has registered — a passkey, TOTP, magic link, anything — not an appendix to one factor. An account whose only factor is a passkey can enrol recovery codes with no TOTP in sight. Never call these “TOTP backup codes”; they are their own AuthMethod, keyed by subject and independent of what else the account has enrolled.

To use recovery codes, enable the recovery-codes feature in your Cargo.toml:

[dependencies]
authkestra-engine = { version = "0.13", features = ["recovery-codes"] }

Recovery codes are implemented via the RecoveryCodeAuthMethod struct. You initialize it by passing a CredentialStore where the code hashes will be saved — the store never sees a plaintext code.

use authkestra::Authkestra;
use authkestra_engine::auth::recovery::RecoveryCodeAuthMethod;
// `my_store` implements the `CredentialStore` trait (e.g. `SqlxCredentialStore`)
let engine = Authkestra::builder()
.with_auth_method(RecoveryCodeAuthMethod::new(my_store.clone()))
.build();

There is no .with_recovery_codes() convenience wrapper the way .with_totp() and .with_webauthn() exist for the two older methods — register it through .with_auth_method(...) as shown above.

Recovery codes are the one method on this track that actually enrols, so generating a set is gated behind a ReproofRequirement — the same re-proof gate that guards enrolling any other sensitive credential. Keep a reference to your RecoveryCodeAuthMethod and call generate, passing the already-authenticated Identity, the gate, and how many codes to issue:

use authkestra_engine::auth::{AuthMethod, ReproofRequirement};
use authkestra_engine::auth::recovery::RecoveryCodeAuthMethod;
let recovery = RecoveryCodeAuthMethod::new(my_store);
// `identity` is the `Identity` from the user's current, freshly-proved session.
// Drive `require_step_up` from whether the account already has a working
// fallback: one that does is made to use it, one that does not (this is
// plausibly its very first enrolment) is gated on freshness alone.
let has_fallback = recovery.has_enrolled(&identity.external_id).await?;
let gate = ReproofRequirement::new(300).require_step_up(has_fallback);
let codes: Vec<String> = recovery.generate(&identity, &gate, 10).await?;
// e.g. ["4KDW-9JMQ-2XPT-R7HV", ...]

Calling generate again replaces the previous set entirely — there is no partial top-up.

remaining reports how many unredeemed codes an account still has, so an application can prompt a user to regenerate before they run out:

let left: usize = recovery.remaining(&identity).await?;

A code is redeemed with AuthInput::RecoveryCode, naming the account and the code as typed:

use authkestra_engine::auth::{AuthInput, AuthResult};
let result = engine.authenticate(AuthInput::RecoveryCode {
user_id: "user_123".to_string(),
code: "4KDW-9JMQ-2XPT-R7HV".to_string(),
}).await?;
match result {
AuthResult::Success(identity) => {
println!("Successfully authenticated as: {}", identity.external_id);
}
AuthResult::MfaRequired { mfa_token, allowed_methods, .. } => {
// A recovery code satisfies an MFA challenge as a step-up factor.
}
}

Redemption is single-use and safe under concurrency: if two requests race to redeem the same code, exactly one succeeds and the other gets AuthError::InvalidCredentials, the same error returned for an unknown or already-used code — nothing about the failure distinguishes the two cases. Dashes and letter case are also normalized before comparison, so 4kdw9jmq2xptr7hv and 4KDW-9JMQ-2XPT-R7HV are the same code as far as redemption is concerned.