FerrisKeyDocs

Recovery codes

Recovery codes are one-time backup codes. A user who has lost their authenticator, because of a lost phone or a wiped app, can type one at the TOTP challenge instead of a six-digit code.

FerrisKey does not create recovery codes by itself. Enrolling an authenticator or a passkey generates nothing, and neither the console nor the default portal has a screen for them. Your application asks FerrisKey for a set, shows it to the user once, and tells them to keep it. If you use the default portal only, nobody has recovery codes.

Generating a set

POST /realms/{realm_name}/login-actions/generate-recovery-codes

The call is made with the user’s own access token as a Bearer token, and it needs an elevation proved with the user’s password. Get one from POST /users/me/reauthenticate, minted for the same session as the token. An elevation proved with an OTP code is refused with a 403.

{
  "elevation_id": "01a0d3dd-a750-7f0f-91d9-cf05f19168c6",
  "amount": 8,
  "code_format": "b32-split-4"
}
FieldDescription
elevation_idThe elevation from reauthenticate
amountHow many codes to create
code_formatb32-split-4 is the only accepted value. Any other gets a 400
{
  "codes": ["ybnd-rfg8-ejkm-cpqx", "..."]
}

Each code is 10 random bytes written as 16 characters of z-base32, in four groups of four. The response is the only time the codes exist in clear. FerrisKey stores a hash of each one as a credential of type recovery-code.

Generating a set replaces the previous one: the new codes are stored first, then every earlier code is deleted. A user has one set at a time.

Using a code

At the TOTP challenge, the client calls burn-recovery-code in place of challenge-otp. Like every login action, it carries the step token cookie and the FERRISKEY_SESSION cookie.

POST /realms/{realm_name}/login-actions/burn-recovery-code
{
  "recovery_code": "ybnd-rfg8-ejkm-cpqx",
  "recovery_code_format": "b32-split-4"
}

FerrisKey hashes the code and compares it with the user’s stored codes. A match deletes that code, so it works once, and the sign-in carries on: the response holds login_url, or the required_actions still due. A code that is wrong or already used gets a 400.

A recovery code only answers a pending TOTP challenge. If the user has no authenticator, the call is refused with a 403 (a recovery code only answers a pending OTP challenge). A user whose only second device is a passkey has no challenge to answer, so recovery codes are of no use to them.

A recovery code does not lift an account lockout, and the client’s maintenance and consent rules still apply after it.

Security model

PropertyBehaviour
StorageOnly a hash is stored. A copy of the database does not give anyone a usable code
Single useA code is deleted the moment it matches
LifetimeA code never expires. It stays valid until it is used or the user generates a new set
ScopeIt replaces the TOTP code at the challenge. It does not replace the password

Treat codes like passwords

A recovery code gets its holder past the second factor. Users should store codes with the same care as a root password: never in plain text on a device, never shared, never in a screenshot.

Building the experience

Show the codes once

The response of generate-recovery-codes is the only time the codes exist in clear. Show them with a copy button and a download option, and avoid rendering them where the browser may cache them or a screen recording may capture them.

Count what is left

GET /realms/{realm_name}/users/me/credentials returns one entry of type recovery-code for each unused code. Show the count, and prompt for a new set when it is low. Each code used shrinks the safety net, and a new set replaces whatever is left.

Admin recovery

If a user loses their authenticator and has no recovery codes, an administrator deletes the one-time password credential from the user’s page in the console, under Authentication methods. The user can then sign in with the password and enrol again.