# devise-two-factor adds TOTP to Devise, and warns about one model setting that breaks it

> devise-two-factor is a MIT licensed Ruby gem that adds TOTP two-factor authentication to a Devise application, with a generator that rewires the model, a Warden strategy that takes the one-time password at sign in, and encrypted storage for the shared secret. It deliberately leaves the screens to you, and it is explicit that loading both database_authenticatable and two_factor_authenticatable silently defeats the whole thing.

**devise-two-factor/devise-two-factor** — Barebones two-factor authentication with Devise

- Repository: https://github.com/devise-two-factor/devise-two-factor
- Stars: 1,313 · Forks: 259
- Language: Ruby
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/devise-two-factor-devise-two-factor

## A backend gem that leaves the screens to you

Devise-Two-Factor is described as a minimalist extension to Devise which offers support for two-factor authentication through the TOTP scheme, time based one-time passwords.

The word minimalist is load bearing. The gem's own description of its scope is that it only worries about the backend and leaves the details of the integration up to you, which means you are responsible for building the user interface that drives it. An example Rails application ships with the gem, but the README is emphatic that the gem is intentionally very open-ended and that you should build a user experience which fits your individual application.

What you do get is a small, opinionated set of pieces. It lets you incorporate two-factor authentication into your existing models, it is opinionated about security so that you do not have to be, it integrates with two-factor applications such as Google Authenticator and Authy, and it is extensible, with two-factor backup codes included as an example of how plugins are structured.

Two workflows are the ones you have to design for: logging in with two-factor authentication, and enabling two-factor authentication for a given user. The reference for how both were handled in practice is Tinfoil Security, where the author registered an account and enabled the second factor from the security settings page.

The audience is therefore a Rails team that has already committed to Devise. This is not a standalone identity system, and it is not a replacement for Devise, it is a module that plugs into it.

## The generator rewires the model, and one mistake defeats it

The setup is a generator, and it does three things to your model. It creates a migration adding a few columns, it edits the model file, and it adds a Warden config block to the Devise initializer that enables the strategies required for two-factor authentication.

The migration adds three columns:

```ruby
add_column :users, :otp_secret, :string
add_column :users, :consumed_timestep, :integer
add_column :users, :otp_required_for_login, :boolean
```

The model edit adds the :two_factor_authenticatable devise module and removes the :database_authenticatable module if it is present.

That removal is the important part, and the README is blunt about the consequence. Loading both :database_authenticatable and :two_factor_authenticatable in a model is a security issue, because it will allow users to bypass two-factor authentication regardless of how otp_required_for_login is set, due to the way Warden handles cascading strategies.

The generator tries to remove the module, but the README notes that with a non-standard Devise setup this step may fail, so verification is left to you: check that :database_authenticatable is not being loaded by the model. The logic behind the module replacement is that :two_factor_authenticatable includes all of :database_authenticatable's functionality, and it will still allow login without a second factor until you enable it on your model's records with otp_required_for_login.

That last point sets the default safely. A newly migrated install does not force every user into two-factor authentication, so the rollout is per record and per user, and an account only becomes a two-factor account when you say so.

## The secret has to be encrypted, so ActiveRecord encryption comes first

There are two prerequisites before you can use the gem: a Rails application with Devise installed, and secrets configured for ActiveRecord encrypted attributes.

The second one is the non obvious requirement. Devise-Two-Factor uses ActiveRecord encrypted attributes, so the otp_secret column holds a value protected by Rails' own encryption layer. If you have not set up ActiveRecord encryption you must generate a key set and configure the application to use them, either with Rails' encrypted credentials or from another source such as environment variables.

The key set is generated by a Rails task:

```bash
# Generates a random key set and outputs it to stdout
./bin/rails db:encryption:init
```

It can be loaded through credentials, and the README notes that setting the EDITOR environment variable is optional but without it your default editor will open:

```bash
# Copy the generated key set into your encrypted credentials file
EDITOR="code --wait" ./bin/rails credentials:edit
```

Or the three settings can come from the environment instead:

```ruby
config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
config.active_record.encryption.deterministic_key = ENV['ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY']
config.active_record.encryption.key_derivation_salt = ENV['ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT']
```

The ordering matters for a deployment. A shared secret that is stored in plain text in the users table is a single table read away from full account takeover, and the consumed_timestep column is there so the same code cannot be replayed. The gem's security posture depends on the application's encryption configuration, not on the gem alone.

## The sign in strategy takes three parameters, one of them is yours to permit

Logging in with two-factor authentication works, in the README's words, extremely similarly to regular database authentication in Devise. The TwoFactorAuthenticatable strategy accepts three parameters: email, password and otp_attempt, the one-time password for that session. These can be submitted to the standard Devise login route, and the strategy handles the authentication for you.

Because it uses the standard route, the sign in controller has to permit the extra parameter, and Devise's strong parameters will reject it by default. The README's instruction is to add a before_action in application_controller.rb and permit:otp_attempt for the:sign_in action:

```ruby
before_action :configure_permitted_parameters, if: :devise_controller?

def configure_permitted_parameters
  devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
end
```

This is the step where a lot of integrations go wrong, and the failure is quiet. A form that posts to the Devise route without the permitted parameter never gets the one-time password into the strategy, and a user with two-factor enabled appears to fail to log in for no visible reason. If you build your own sign in form, the one-time password field has to be part of it and named otp_attempt.

One more consequence of reusing the Devise route: the login page for a two-factor account and for a normal account are the same endpoint, so the interface has to handle the case where the second factor is required, which is exactly the part the gem leaves to you.

## A password reset would otherwise walk straight past the second factor

There is one Devise behaviour that quietly breaks the security model, and the README calls it out separately.

If you use the Devise recoverable strategy, the default behaviour after a password reset is to automatically authenticate the user and log them in. The README does not hedge: this is obviously a problem if a user has two-factor authentication enabled, as resetting the password would get around the two-factor requirement.

The fix is to set sign_in_after_reset_password to false, either globally in the Devise initializer or via devise_for. Then the user is returned to the login form after resetting their password and has to pass the second factor like anyone else.

This is the kind of setting that a user will never report, because nothing visibly breaks. The reset works, the email arrives, the user gets back in. The second factor simply never got a chance to run. The same reasoning applies to any other path that establishes a session without a password, and the README does not enumerate them, so a team enabling this gem has to look at how their own application signs users in rather than assume the Devise defaults are safe.

It is also the reason the gem describes itself as opinionated about security. The rules are not many, but they are specific, and each one corresponds to a way the second factor could be bypassed.

## Backup codes are the template for a plugin

The last item on the feature list is the one about extensibility, and the backup codes implementation is the example of how a plugin is structured.

That framing matters for a library that covers only the TOTP path. A user who loses their authenticator app is a real operational problem, and the backup code mechanism is a second, independent way to satisfy the second factor. Implementing it as a plugin rather than inside the core shows the intended extension model: a plugin adds a way to produce or supply the second factor value, and the core does not need to know about it.

The same idea generalises. A deployment that wants a different authenticator flow, a hardware key, or an approval through a phone push could be built as a plugin in the same shape, and the README points at the backup code implementation as the reference.

The extension surface is deliberately narrow in one respect. The core only handles the backend, and the strategy contract is the three parameters of email, password and otp_attempt. A plugin therefore changes how the second factor is obtained or supplied, not the shape of the sign in flow, which keeps the integration work in the application rather than in the gem.

## Install, generate, migrate, and the checks that follow

Adding the gem to the application is one line in the Gemfile:

```ruby
gem 'devise-two-factor'
```

Then the generator does the wiring, with MODEL replaced by the name of the model you want to configure, for example User or Admin:

```bash
# MODEL is the name of the model you wish to configure devise_two_factor e.g. User or Admin
./bin/rails generate devise_two_factor MODEL
```

Apply the migration it created afterwards:

```bash
./bin/rails db:migrate
```

Before you call it done, walk the checklist the README gives. Confirm the model loads :two_factor_authenticatable and not :database_authenticatable. Confirm :otp_attempt is permitted in the sign in controller. Confirm sign_in_after_reset_password is false. Confirm ActiveRecord encryption is configured, because the otp_secret is written through it. Then enable otp_required_for_login on the records that should require a second factor, since until you do, the two_factor_authenticatable module still allows login without one.

For a working reference, the example application is in the demo/ directory of the repository, with its own README.

On maintenance, the gem is MIT licensed and the repository is not archived, with the last push on 2026-10-08 and the published release v6.4.0 from 2026-02-02. The repository also carries CHANGELOG.md, UPGRADING.md, SECURITY.md and CONTRIBUTING.md, and the contributing notes ask for help getting the gem fully compatible with Rails 5+ and squashing deprecation messages, which tells you where the remaining rough edges are.

## Conclusion

devise-two-factor suits a Rails team that already runs Devise, wants TOTP second factor support in the model rather than a bolted on service, and is prepared to build its own screens because the gem deliberately stops at the backend. It does not suit an application that has not settled on ActiveRecord encrypted attributes, since the secret has nowhere safe to live until that is in place, and it is the wrong choice for a team that wants a hosted identity provider instead of managing second factors itself. Verify first that your model loads only the two_factor_authenticatable module, because the README calls having both a security issue that lets users bypass the second factor whatever otp_required_for_login says, and confirm that sign_in_after_reset_password is set to false so a password reset cannot skip the check.

## FAQ

### What does devise-two-factor add to Devise?

It adds TOTP based two-factor authentication to an existing Devise application, with support for a one-time password submitted alongside email and password, encrypted storage of the shared secret, and an extensible plugin model demonstrated by the backup codes implementation.

### What does the devise_two_factor generator do?

It creates a migration adding otp_secret, consumed_timestep and otp_required_for_login columns, edits the model to add the :two_factor_authenticatable module and remove :database_authenticatable, and adds a Warden configuration block to the Devise initializer. You still need to run the migration afterwards.

### Why does the README say loading both Devise modules is a security issue?

Because of the way Warden handles cascading strategies, having both :database_authenticatable and :two_factor_authenticatable loaded will let users bypass two-factor authentication regardless of how otp_required_for_login is set. The generator removes the module, but the README asks you to verify it, since the step can fail in a non standard Devise setup.

### How do I stop a password reset from bypassing the second factor?

Set sign_in_after_reset_password to false, either globally in the Devise initializer or via devise_for. The default recoverable behaviour authenticates and logs the user in after a reset, which would let them around the two-factor requirement.

### Does devise-two-factor build the user interface for me?

No. The README states that the gem only worries about the backend and leaves the integration to you, and that it is intentionally very open-ended. An example Rails application is included in the demo directory for reference.

## Sources

- [devise-two-factor/devise-two-factor on GitHub](https://github.com/devise-two-factor/devise-two-factor)
- [Issues](https://github.com/devise-two-factor/devise-two-factor/issues)
- [License: MIT](https://github.com/devise-two-factor/devise-two-factor/blob/main/LICENSE)
- [README](https://github.com/devise-two-factor/devise-two-factor/blob/main/README.md)
- [Releases](https://github.com/devise-two-factor/devise-two-factor/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/devise-two-factor-devise-two-factor
