2

Let's say I have a simple model like so with a presence validation on the password field.

class User < ApplicationRecord
  validates :password, presence: true
end

If I try to update the password to a blank, the validation fails. It acts the same whether the record object was just create or if it was loaded from the database. All normal.

cuser = User.create!(name: "Foo", password: "abc123")
  TRANSACTION (0.0ms)  begin transaction
  User Create (0.5ms)  INSERT INTO "users" ("name", "password", "password_digest", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?) RETURNING "id"  [["name", "Foo"], ["password", "[FILTERED]"], ["password_digest", "[FILTERED]"], ["created_at", "2024-06-21 18:47:34.844263"], ["updated_at", "2024-06-21 18:47:34.844263"]]
  TRANSACTION (0.1ms)  commit transaction

cuser.update!(name: "Bar", password: "")
  Validation failed: Password can't be blank (ActiveRecord::RecordInvalid)

fuser = User.last
  User Load (0.1ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]

fuser.update!(name: "Other", password: "")
  Validation failed: Password can't be blank (ActiveRecord::RecordInvalid)

If I only use has_secure_password...

class User < ApplicationRecord
  has_secure_password
end

user.update!(name: "Bar", password: "") works for a created and found record. The password change is ignored. This is not a documented feature of has_secure_password, and maybe it should be, but that's not what I'm asking about.

cuser = User.create!(name: "Foo", password: "abc123")
  TRANSACTION (0.1ms)  begin transaction
  User Create (0.6ms)  INSERT INTO "users" ("name", "password", "password_digest", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?) RETURNING "id"  [["name", "Foo"], ["password", "[FILTERED]"], ["password_digest", "[FILTERED]"], ["created_at", "2024-06-21 18:56:38.988813"], ["updated_at", "2024-06-21 18:56:38.988813"]]
  TRANSACTION (0.1ms)  commit transaction

cuser.update!(name: "Bar", password: "")
  TRANSACTION (0.1ms)  begin transaction
  User Update (0.4ms)  UPDATE "users" SET "name" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["name", "Bar"], ["updated_at", "2024-06-21 18:56:55.334524"], ["id", 21]]
  TRANSACTION (0.1ms)  commit transaction

fuser = User.last
  User Load (0.1ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]

fuser.update!(name: "Other", password: "")
  TRANSACTION (0.1ms)  begin transaction
  User Update (0.4ms)  UPDATE "users" SET "name" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["name", "Other"], ["updated_at", "2024-06-21 18:57:06.994792"], ["id", 21]]
  TRANSACTION (0.1ms)  commit transaction

Look what happens if I use both together.

class User < ApplicationRecord
  has_secure_password
  validates :password, presence: true
end

user.update!(name: "Bar", password: "") works, but only if the record was just created. If the record was found, the validation fails.

cuser = User.create!(name: "Foo", password: "abc123")
  TRANSACTION (0.1ms)  begin transaction
  User Create (0.5ms)  INSERT INTO "users" ("name", "password", "password_digest", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?) RETURNING "id"  [["name", "Foo"], ["password", "[FILTERED]"], ["password_digest", "[FILTERED]"], ["created_at", "2024-06-21 18:46:26.576676"], ["updated_at", "2024-06-21 18:46:26.576676"]]
  TRANSACTION (1.2ms)  commit transaction

cuser.update!(name: "Bar", password: "")
  TRANSACTION (0.1ms)  begin transaction
  User Update (0.3ms)  UPDATE "users" SET "name" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["name", "Bar"], ["updated_at", "2024-06-21 18:46:26.579666"], ["id", 17]]
  TRANSACTION (0.1ms)  commit transaction

fuser = User.last
  User Load (0.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]

fuser.update!(name: "Other", password: "")
  Validation failed: Password can't be blank (ActiveRecord::RecordInvalid)

That's very strange behavior. It's as if has_secure_password is actively suppressing the password presence validation, but only if #previously_new_record? is true. I understand why it might want to suppress the validation (a warning or error about conflicting validations would be better), but why only for newly created objects?

Is this a bug in has_secure_password? Or is there some reason for it to behave this way?


To reproduce...

  • rails new
  • rails g model user name:string password:string
  • Uncomment gem "bcrypt" in your Gemfile.

Ruby 3.2.2 Rails 7.1.3.4 Gemfile.lock

For context, see How to prevent password from being updated if empty in Rails Model and test it with RSpec?.

1

1 Answer 1

2

So the first part of the question regarding the ignorance of an empty password is attributed to InstanceMethodsOnActivation where the setter for password ignores empty entirely (Source)

Additionally, has_secure_password actually validates that password_digest is present (Source) rather than password itself.

As for your second example, password is a virtual attribute (Source) so even if you passed nothing the error would be raised.

It basically works like this:

  • cuser = User.create!(name: "Foo", password: "abc123") - sets the virtual attribute for password
  • cuser.update!(name: "Bar", password: "") - does not update password because the setter ignores the empty value, so the virtual attribute is still set to "abc123" and validation passes
  • fuser = User.last - retrieved record does not have the password virtual attribute set. If you ran fuser.valid? here it would return false
  • fuser.update!(name: "Other", password: "") - setter ignores empty so the virtual attribute is still not set and validation fails.

If you really want to validate an empty password it appears the simplest means would be to modify the setter for password e.g.

def password=(value) 
  value = nil if value.empty? 
  super(value) 
end 
2
  • That explains why it's happening, thanks. Does it seem deliberate? What is your opinion about this being a bug/undesirable behavior?
    – Schwern
    Commented Jun 21 at 19:58
  • @Schwern well based on the source code using nil instead of "" will preform based on your expectations. Given that there was some accounting for this process I wouldn't call it a bug. As for the desirable part, I would say that the password is not updated if it is blank and is initially validated as such (by avoiding setting) so this seems desirable. Additionally loading a record directly from the database and having it already be invalid due to password validation seems undesirable. I am thinking maybe just a documentation update would be in order. Commented Jun 21 at 20:03

Not the answer you're looking for? Browse other questions tagged or ask your own question.