Rails Login Security

Posted 3 months ago by Vasily

Web security is hard. There are so many little things that developers have to keep track of and remember. Take login forms, for example: they are an initial point of entry in almost any web app and are exposed not just to individuals but also to scripts and web crawlers. It means that login forms can be exploited automatically.

So, what can go wrong? For starters, an attacker can write a brute force script that will try millions of login and password combinations in a very short period of time, eventually hacking into some user accounts.

What if you have basic account locking implemented that some authentication libraries, like Devise, offer out of the box? The attacker can still cause havoc by attempting to connect to multiple accounts N times, where N is the number of attempts after which an account gets locked. Imagine a script that tries to login to your app with millions of stolen email addresses. After a while many users will be locked out of their accounts. Not a great experience especially if the script is looped.

In this article I am going to address those issues and show how to implement a CAPTCHA-protected lockable login form with Rails 4 and Devise 3.

Before we dive in, I’d like to point out that this solution is not ideal in the sense that it relies on the Devise lockable module. Ideally, I’d like to create a decoupled Devise module (e.g., :captcha_protected or captchable). If there is enough interest from the community, I will implement it in the form of a gem.

General Flow

First off, let’s figure out what we want the login flow to be. I am making an assumption that the reader uses email and password as login credentials and that she implemented standard authentication with Devise and Rails.

In the ideal case, the user inputs their email and password in the login form fields and accesses the app. What happens if she can’t remember the exact password and tries different combinations? Devise’s default behavior is to allow as many attempts as possible. It’s not a secure behavior. To make it secure and not let the user make an infinite number of login attempts we’ll have to enable the lockable behavior that locks the account after N unsuccessful login attempts.

But what if it wasn’t the user who was making unsuccessful login attempts but a script whose target is to lock as many accounts as possible? CAPTCHA to the rescue! We want our CAPTCHA to kick in before account locking, which will give the attacker’s script a hard time locking an account: if CAPTCHA is not passed, then the login attempt stops mid-process and the user is asked to try again.

So, what’s the final path for a secure login? Here is my take:

  • receive user credentials
    • account locked?
      • cancel login
    • account not locked?
      • CAPTCHA required?
        • CAPTCHA correct?
          • login and password correct?
            • login user
          • login and password incorrect?
            • increase the number of failed attempts
            • failed attempts > max login attempts?
              • lock account
            • cancel login
        • CAPTCHA incorrect?
          • cancel login
      • CAPTCHA not required?
        • login and password correct?
          • login user
        • login and password incorrect?
          • increase the number of failed attempts
          • cancel login

Phew! What a flow. Let’s dig into the implementation details, shall we?

Account Locking

First things first: let’s setup the Devise :lockable module. It’s very simple. Add :lockable to the user model like this:

class User < ActiveRecord::Base
  devise :lockable, ...

Now go to the Devise initializer under config/initializers/devise.rb and uncomment the configuration for :lockable:

config.lock_strategy = :failed_attempts
config.maximum_attempts = 10
config.unlock_keys = [ :email ]
config.unlock_strategy = :both
config.unlock_in = 1.hours

So, what’s going on here? First, we set the default :failed_attempts strategy for account locking and :maximum_attempts that sets the number of attempts after which the account is locked. Then we specify the unlock keys that are used to lock and unlock the account. Since we don’t have any extra username fields let’s use email. Next up is the unlock strategy. Depending on your requirements you can choose :email, :time, :both, or :none. The email unlock strategy locks the account until it’s unlocked by the user. It sends a notification email to the user saying that her account was locked. It will also include a link for unlocking. The time unlock strategy locks the account temporarily and unlocks it automatically in :unlock_in time.

You can use either one of those strategies depending on what you need. For example, if you have to follow PCI Data Security Standards then the following should be setup in your app:

8.5.13 Limit repeated access attempts by locking out the user ID after not more than six attempts.

8.5.14 Set the lockout duration to thirty minutes or until administrator enables the user ID.

These requirements are pretty draconian towards the user and are the opposite of good UX but sometimes you have to do it.

The last thing we need to setup is the users table. Either uncomment the appropriate fields in your original Devise migration before running it or add the following standalone migration:

class AddLockableToUsers < ActiveRecord::Migration
  def change
    add_column :users, :failed_attempts, :integer, default: 0
    add_column :users, :unlock_token, :string
    add_column :users, :locked_at, :datetime

    add_index :users, :unlock_token, unique: true

After the initial setup of the :lockable module is complete, let’s look at CAPTCHA.


There are many options for CAPTCHAs out there with Recaptcha being my personal favorite. It supports a great cause, it’s easy to setup, and it’s free.

To use Recaptcha add the Recaptcha gem to your gemfile:

gem 'recaptcha', require: 'recaptcha/rails'

and run bundle install. Then get private and public keys from the Recaptcha control panel and setup an initializer in config/initializers/recaptcha.rb:

Recaptcha.configure do |config|
  config.public_key  = ENV['CAPTCHA_PUBLIC_KEY']
  config.private_key = ENV['CAPTCHA_PRIVATE_KEY']
  config.use_ssl_by_default = true

That’s it! Now you can use Recaptcha gem built-in recaptcha_tags and verify_recaptcha methods. If you want to learn more about how they work, please refer to the docs.

It’s finally time to combine the power of Devise with Recaptcha and setup a secure login form for your app!

Putting it all Together

In order for everything to work together, we’ll need to write a custom SessionsController class and add a custom view to views/sessions/new.html.*. The latter will overwrite the default Devise view for logins. You can copy-paste the whole view from GitHub and modify it (e.g., rewrite in haml). The only extra thing that we have to add is the Recaptcha helper that will render a CAPTCHA if the number of login attempts exceeds some threshold:

- if User.find_by_email(resource.email)
  - if User.find_by_email(resource.email).failed_attempts > User.logins_before_captcha
    = recaptcha_tags

What’s User.logins_before_captcha? This is just a reusable value that defines how many logins can be attempted before Recaptcha kicks in. One way to do it is in the model class method:

class User < ActiveRecord::Base
  def self.logins_before_captcha

Now let’s add a custom SessionsController to controllers/users/sessions_controller.rb where the magic of the flow happens.

class Users::SessionsController < Devise::SessionsController
  def create

    user = User.find_by_email(sign_in_params['email'])
    super and return unless user

    adjust_failed_attempts user

    super and return if (user.failed_attempts < User.logins_before_captcha)
    super and return if user.access_locked? or verify_recaptcha

    # Don't increase failed attempts if Recaptcha was not passed
    decrement_failed_attempts(user) if recaptcha_present?(params) and

    # Recaptcha was wrong
    self.resource = resource_class.new(sign_in_params)
    flash[:error] = 'Captcha was wrong, please try again.'
    respond_with_navigational(resource) { render :new }

  private def adjust_failed_attempts(user)
    if user.failed_attempts > user.cached_failed_attempts
      user.update cached_failed_attempts: user.failed_attempts

  private def increment_failed_attempts(user)
    user.increment :cached_failed_attempts
    user.update failed_attempts: user.cached_failed_attempts

  private def decrement_failed_attempts(user)
    user.decrement :cached_failed_attempts
    user.update failed_attempts: user.cached_failed_attempts

  private def recaptcha_present?(params)

To activate your custom SessionsController modify your routes.rb:

AppName::Application.routes.draw do
  devise_for :users, controllers: { sessions: 'users/sessions' }, ...

The new controller should be pretty self explanatory. The only bit that requires extra explanation is the cached_failed_attempts user attribute. Since the technique that I am describing in this article is not completely decoupled from the :lockable Devise module, we can’t rely on the failed_attempts user attribute to base our logic off of. There are a couple of edge cases where Devise resets this attribute to zero, breaking the Recaptcha flow. This is why we need a “cached” copy of this field that Devise won’t have access to. Create a migration that adds this field to the users table:

add_column :users, :cached_failed_attempts, :integer, default: 0

And add this attribute to the user model:

class User < ActiveRecord::Base
  attr_accessible :cached_failed_attempts, ...

Finally, let’s reset failed attempt counters in ApplicationController once the user logs in:

def after_sign_in_path_for(resource)
  resource.update cached_failed_attempts: 0, failed_attempts: 0

Now we have a working secure login form that not only provides protection against attackers and scripts but also makes for a much nicer user experience.

Please let me know your thoughts about the article! I’d also like to know if there is any interest in having a standalone gem that extends Devise with the :captcha_protected module.