Two Factor / TOTP Backup Codes

Provide users with a fixed number of backup codes after setting up two-factor authentication with TOTP, in case they lose their authenticator data.

Level: Easy
8
180
6

Add to existing TOTP functions

When we first built TOTP into our app we added several new functions in our Accounts context. Here, we'll add to those functions so that backup codes are generated and deleted when TOTP is enabled and disabled, respectively.

Then, we add functions for generating, listing, and consuming backup codes. To consume a backup code, we query for the code while scoping our query to the signed-in user. Requiring a user is a security control, as backup codes are not typically long enough to be secure on their own.

A backup code is valid if our scoped query deletes exactly one record. Once verified, we use the same underlying function that is used for verifying a normal TOTP code to sign the user in. Since we deleted the backup code in this process, it is not reusable.

Association with the user struct

While not strictly necessary (we won't use this field to preload data anywhere), we document the has_many relationship between our User and UserBackupCode structs here.

The UserBackupCode schema

There is not a lot to this module in particular. One thing to note is that it has a composite primary key, using the code and user_id fields. This is the "natural" key of the record, because it signifies identity. It would not make sense for multiple records in this table to have this combination of identical values. So, we spare ourselves of the need to generate a meaningless primary key.

To build a backup code string, we sample some random bytes and hex encode them. The number of bytes is tunable with the @rand_bytes module attribute. These bytes do not have to be particularly long because they are not global, rather they are scoped to a single user.

The query function used to consume backup codes is simple, we just select for codes based on the compound primary key of code and user.

Showing backup codes once

We only want to show backup codes to the user once, immediately after they first enable TOTP. This is because this is when we are most sure that the current user should in fact have access to these secrets. To accomplish this, we list codes assigned to the current user in the LiveView handler for enabling TOTP, and when these codes are present show a modal containing them.

Consuming a backup code

To consume a backup code, we'll render a simple LiveView form for accepting the code and call our Accounts.consume_backup_code(user, user_token, code) function. If the function returns :ok, the code was valid and consumed, and we can forward the user to where they belong in the app.

Showing the backup code option

In the LiveView that handles TOTP, we show a link to use a backup code. This is the most likely place for a user to realize they no longer have access to their authenticator device :)

Router changes

In our router, we just need to add our new LiveView for consuming backup codes. This should be placed in a scope that requires a signed-in user.

Migration to add backup codes

Our table is straightforward as it only includes the code string and the ID of the user it belongs to. We do not even need to store any kind of status or redeemed_at field, as our design decision dictate that only the presence of a code is enough to determine validity. Remember to include the index as well, otherwise the performance of deleting codes (an operation which uses only user_id) may degrade over time.