About

Why a password_digest Column Breaks Devise's inspect

Devise's password_digest(password) shadows a leftover has_secure_password column. Staging looked fine only because pry swallowed the exception

If your Rails app uses Devise and printing a user in the console gives you An error occurred when inspecting the object, or user.as_json dies with ArgumentError, this is for you. By the end you can find the culprit column with one snippet, and fix it without starting to leak your API token.

Short version: if the table has a password_digest column, Devise's own method of the same name, password_digest(password), shadows the column reader. Devise's inspect reads every attribute with send, calls it with no arguments, and blows up.

It only blew up in the production console

On a work project (Rails 7.1, Devise 4.9) I looked up one user in the production console and got this.

irb(main):001> user = User.find_by(email: 'someone@example.com')
An error occurred when inspecting the object: #<ArgumentError: wrong number of arguments (given 0, expected 1)>
Result of Kernel#inspect: #<User:0x00007f528b334d40 @new_record=false, @attributes=...
irb(main):002> user.email
=> "someone@example.com"

The data is fine. Only the display died. Do the same thing on staging and there's no error.

[1] pry(main)> user = User.find_by(email: 'admin@example.com')
=> #<User:0x6978>

So I started by digging for something odd in the production data. Wrong starting point. It wasn't an environment difference. It was a REPL difference.

Staging looked fine because pry swallowed it

The clue was #<User:0x6978> itself. A working ActiveRecord inspect shows attributes, like #<User id: 17, email: ...>. Ruby's default inspect shows a 16-digit address. Four digits is neither.

pry's ColorPrinter#pp rescues exceptions raised by inspect and prints a fallback built from the class name and object.__id__.to_s(16). Object ids are small sequential numbers, so it's short. irb has no such rescue, so it prints "An error occurred when inspecting the object" as is. In this blog's repo (pry 0.15.2), passing the same kind of model to Pry::ColorPrinter.pp printed just #<ReproUser:0x7544> and no exception got out.

Staging used pry because of RAILS_ENV. pry-rails sits in the Gemfile's group :development, :test, and Rails only requires the groups for the current environment through Bundler.require(*Rails.groups). The staging container was running with RAILS_ENV=development, so pry took over the console. Production was production, so it got irb. The gem was installed in both images. The difference was require, not install.

So when someone says "it works on staging", line up the REPL first. Reproduce with bin/rails runner, not the console. With no REPL in the way, staging blew up exactly like production.

The cause: Devise already uses that name

At the bottom of the backtrace from runner was Devise's inspect. Devise redefines inspect on top of serializable_hash so passwords don't leak into exception messages. ActiveModel's serializable_hash calls send for every attribute name (alias :read_attribute_for_serialization :send).

And Devise's DatabaseAuthenticatable already has a method that hashes passwords.

# devise-4.9.4/lib/devise/models/database_authenticatable.rb:197
def password_digest(password)
  Devise::Encryptor.digest(self.class, password)
end

It takes one argument. send(:password_digest) passes zero. Hence wrong number of arguments (given 0, expected 1).

send(:password_digest) walks User.ancestors from the topinspect → serializable_hash → send(name) for every attributeUserno password_digest hereDevise::Models::DatabaseAuthenticatablepassword_digest(password)found first. arity 1, called with 0 → ArgumentErrorother modules included in the class bodyUser::GeneratedAttributeMethodspassword_digestthe column reader. arity 0, never reachedApplicationRecord, ActiveRecord::BaseAR includes the column reader module when the class is created (inherited hook).Anything you include later, like devise, sits above it. Same name → the column loses.

The column reader does exist. AR defines a password_digest reader in User::GeneratedAttributeMethods. But that module is included the moment the class is created (the inherited hook), and devise ... in the class body is included after it. Later includes sit higher in the ancestor chain, so when names collide, the column loses. User.instance_method(:password_digest).owner returns Devise::Models::DatabaseAuthenticatable.

I also checked why AR never warned. dangerous_attribute_method?, which decides whether a column name is dangerous, only looks at methods defined by ActiveRecord::Base itself. A column named save raises DangerousAttributeError right away. password_digest returns false. Collisions with gems or concerns get shadowed silently.

Why the column was there at all

password_digest is the fixed column name Rails' standard has_secure_password uses. Devise does the same job with encrypted_password. So there were two password hash columns.

Digging through git, add_column :users, :password_digest and a #has_secure_password comment landed on the same day. It was commented out from the start and never turned on. Someone weighed the two, picked Devise, and by then the migration had already run, so only the column stayed. In production, 0 of the 42,212 users rows had a value in it. An empty column had been killing inspect for over two years.

It's not just inspect. as_json, to_json and serializable_hash die too. Single attributes like user.email and queries work fine. It survived only because nothing rendered a User as JSON. One render json: user and you get a 500.

The fix: ignored_columns alone leaks in two places

The first move was self.ignored_columns += %w[password_digest]. The attribute is gone, so nothing calls send on it. Then a self-review found two holes.

One. ignored_columns only applies when AR builds attributes from the schema. If a SELECT brings the column back, like select('users.*'), the attribute is revived and it blows up again. The service code really had a query like that.

Two. The moment it's fixed, things you couldn't see before start showing. Devise only hides its UNSAFE_ATTRIBUTES_FOR_SERIALIZATION list (encrypted_password, reset_password_token and so on), and authentication_token, which that project used for API auth, isn't on it. Re-running it in this blog's repo, inspect masked it as [FILTERED] thanks to :token in filter_parameters, but as_json and to_json returned the token in plain text. And if ActiveRecord loads before your initializers, filter_attributes stays an empty array and inspect is plain text too. This blog's repo is like that.

So the final fix drops both in serializable_hash.

class User < ApplicationRecord
  self.ignored_columns += %w[password_digest]

  EXCLUDED_FROM_SERIALIZATION = %i[password_digest authentication_token].freeze

  def serializable_hash(options = nil)
    options = options.try(:dup) || {}
    options[:except] = Array(options[:except]).dup
    options[:except].concat(EXCLUDED_FROM_SERIALIZATION)
    super(options)
  end
end

except removes names from the list before send is ever called, so it's safe even when select('users.*') revives the column. Devise's serializable_hash is still reached through super, so Devise's own hiding keeps working. Here's the reproduction on Rails 8.0.4 and Devise 4.9.4.

before                    inspect, as_json, to_json  ArgumentError
ignored_columns only      inspect OK, authentication_token in as_json
  + select('users.*')     inspect ArgumentError (again)
with except               inspect OK, as_json keys = [id, email]

One catch: only and except are an elsif, so pinpointing it with as_json(only: [:password_digest]) still blows up. Not that you'd ever call it that way.

Drop the column in the next release

It's tempting to put remove_column in the same PR, but on a rolling deploy that's an outage, because old code still writes the dropped column in its INSERTs. Split it: ship ignored_columns → confirm every old container is gone → remove_column → delete the ignored_columns line. The why is in Why remove_column Breaks INSERTs During a Rolling Deploy.

Does my app have one? Scan them all at once

This lists only columns whose reader is shadowed by another module, where that method requires arguments. Run it with bin/rails runner.

Rails.application.eager_load!

ActiveRecord::Base.descendants
  .reject { |m| m.abstract_class? || m.name.nil? || !m.table_exists? }
  .each do |klass|
    klass.define_attribute_methods rescue next
    klass.column_names.each do |c|
      m = (klass.instance_method(c) rescue next)
      next if m.owner.to_s.include?('GeneratedAttributeMethods')
      next if m.arity == 0 || m.arity == -1   # harmless if callable with no args
      puts "#{klass.name}##{c} -> #{m.owner} (arity #{m.arity})"
    end
  end

On the work project (80 models) it printed exactly one line: User#password_digest -> Devise::Models::DatabaseAuthenticatable (arity 1). On this blog's repo it prints nothing. id is shadowed by AR's PrimaryKey module in almost every model, but it's arity 0 and harmless, so it's filtered out.

Half-finished migrations like this one, left over from weighing has_secure_password against Devise, are more common than you'd think. Roll back migrations you won't use. The column stays, the intent doesn't. Two years later, the only clue to the cause was a single #has_secure_password comment.

If your older Rails app has piled up columns nobody can explain, or you want a second pair of eyes on where tokens leak while you clean up auth, feel free to ask negabaro.

pry vs irb: when inspect raises

pry (pry-rails)irb
What you see#<User:0x6978>An error occurred when inspecting the object
The exceptionRescued by ColorPrinter, hiddenPrinted as is
When you get itIts Gemfile group matches the current RAILS_ENVpry-rails is not required

has_secure_password vs Devise: same job, different names

has_secure_passwordDevise database_authenticatable
Hash columnpassword_digestencrypted_password
The name password_digestA column (no args)A method that builds the hash (1 arg)
Pick it whenA small app that only needs login. Rails 8's auth generator goes this wayYou need password reset, lockout and OAuth in one go
Both at onceThe Devise method shadows the column and inspect / as_json die. Pick one and drop the other's column

Key Points

1

If "it works on staging", check the REPL first. pry swallows inspect errors. Reproduce with bin/rails runner

2

Use User.instance_method(:password_digest).owner to see who shadows the column reader

3

Make AR forget the column with ignored_columns, and drop it plus secret tokens with except in serializable_hash

4

Run remove_column in the release after ignored_columns has fully rolled out

5

Use the snippet to find, across all models, columns shadowed by methods that need arguments

Use Cases

When printing a Devise model in the console gives "An error occurred when inspecting the object" Cleaning up the users table of an older Rails app that went back and forth between has_secure_password and Devise
← Why remove_column Breaks INSERTs During a Rolling Deploy