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.

v1.8.3 v1.7.12
Level: Easy
8
219
4

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.

The enable_user_totp/4 function now returns {:ok, {user, backup_codes}} on success, where backup_codes is a list of plain-text codes to display to the user once.

Then, we add functions for generating, counting, 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_size module attribute (a size of 6 generates 12-character hex codes). These bytes do not have to be particularly long because they are not global, rather they are scoped to a single user.

The codes are stored hashed in the database using SHA256, so they cannot be retrieved later. The query function used to consume backup codes hashes the input and selects 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.

When TOTP is enabled, the backup codes are returned from the enable_user_totp/4 function and stored in a @totp_backup_codes assign. When this assign is present, we display a warning alert with the codes in a grid. The codes are displayed in a monospace font for easy reading.

Consuming a backup code

To consume a backup code, we 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 verification, 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 add the new LiveView for consuming backup codes at /users/verify-backup . This is placed in the :require_authenticated_user live_session alongside the TOTP verification route, as both require a signed-in user but not TOTP verification.

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 dictates 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.