Move from MD5 to bcrypt hashed password without bothering users

Manfred Stienstra

In an application we wrote back in 2004 I found MD5 hashed passwords. We decided this was too weak for modern standards so we wanted to switch to bcrypt. During the move we wanted the user to be affected as little as possible.

In order to compute the crypted password we need the cleartext version. We only have a hashed version so the user has to type her password. Luckily they do this every time they authenticate, so that is a nice opportunity to upgrade their password.

First I added a crypted_password column to the accounts table. We now have two columns for storing the password: the old hashed_password and the new crypted_password.

add_column :accounts, :crypted_password, :string

After that we updated the password accessor methods; assignment and verification.

class Account
  def password=(password)
    if new_record? or !password.blank?
      self.crypted_password = BCrypt::Password.create(password)
    end
  end
  
  def has_password?(input)
    BCrypt::Password.new(crypted_password) == input
  rescue BCrypt::Errors::InvalidHash
    false
  end
end

Now need to make sure we can authenticate with both the hashed as well as the crypted password stored for an account.

class Account
  def self.authenticate_with_crypt(params={})
    if account = find_by_username(params[:username]) and
       account.has_password?(params[:password])
      account
    end
  end
  
  def self.hash_password(password)
    ::Digest::MD5.hexdigest(password)
  end
  
  def self.authenticate_with_md5(params={})
    find_by_username_and_hashed_password(
      params[:username],
      hash_password(params[:password])
    )
  end  
end

Finally we need to make sure the password automatically updates. We try to authenticate using bcrypt. BCrypt raises an exception when the crypted_password is blank. This makes authentication fail and we fall back to trying the hashed password. When authentication with a hashed password succeeds we know the cleartext password and we can update it.

class Account
  def self.authenticate(params={})
    if account = authenticate_with_crypt(params)
      account
    elsif account = authenticate_with_md5(params)
      account.password = params[:password]
      account.hashed_password = nil
      account.save!
      account
    else
      account = Account.new(params.slice(:username, :password))
      account.errors.add_to_base("The credentials you entered are"
        "invalid. Please try again.")
      account
    end
  end
end

This solution will leave a group of users with a hashed password indefinitely. After a few months we could decide to throw away the hashed passwords. This means that infrequent users will have to reset their password if they do decide to log in again. It could cause some support requests, but I think we can handle them.

Note that this change doesn’t make the application safer. In case we leak information or when the database is somehow stolen it will make it harder to recover passwords. A lot of people use the same password for multiple accounts so this will give them time to reset their other accounts in case it is compromised. The change only took 15 minutes in this application so it’s totally worth the time.