Prevent logging sensitive information in Rails, and beyond

Steve Polito

By default, Rails filters out sensitive request parameters from your log files. I’ve found the default values are a good foundation, and account for almost all use cases.

# config/initializers/filter_parameter_logging.rb

Rails.application.config.filter_parameters += [
  :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
]

If a request is made that contains a parameter that partially matches any of these values, it will be filtered.

Parameters: {"authenticity_token"=>"[FILTERED]", "email_address"=>"[FILTERED]", "password"=>"[FILTERED]", "commit"=>"Sign in"}

It also filters the associated attributes.

<User:0x000000011f735030
 id: 980190962,
 email_address: "[FILTERED]",
 password_digest: "[FILTERED]",
 created_at: "2025-04-23 13:59:46.324377000 +0000",
 updated_at: "2025-04-23 13:59:46.324377000 +0000">

Don’t just filter sensitive information, encrypt it

There’s a case to be made that you should rarely need to update the default list. This is because if you plan on storing anything worth filtering, it should be encrypted.

class User < ApplicationRecord
  encrypts :phone_number
end

Fortunately, Rails has accounted for this by automatically filtering encrypted attributes.

Note how the phone_number is filtered when logging internal requests.

Parameters: {"authenticity_token"=>"[FILTERED]", "user"=>{"email_address"=>"[FILTERED]", "phone_number"=>"[FILTERED]", "password_digest"=>"[FILTERED]"}, "commit"=>"Create User"}

It’s also filtered when inspecting an object.

<User:0x0000000121282608
 id: 980190962,
 email_address: "[FILTERED]",
 password_digest: "[FILTERED]",
 created_at: "2025-04-23 14:10:49.414784000 +0000",
 updated_at: "2025-04-23 14:10:49.414784000 +0000",
 phone_number: "[FILTERED]">

Filter sensitive information from external network requests

Just because Rails provides a good foundation doesn’t mean it accounts for everything.

For example, if you’re using Faraday, it’s your responsibility to filter sensitive information when logging requests. This does not happen by default.

conn = Faraday.new(url: "http://httpbingo.org") do |builder|
  builder.request :json
  builder.response :json
  builder.response :raise_error
  builder.response :logger, nil, {
    headers: true,
    bodies: true,
    errors: true
  }
end

conn.get("get", api_key: "secret")
conn.post("anything", user: User.last!.as_json)

We’re exposing the api_key when logging both the request and the response when making a GET request.

INFO -- : request: GET http://httpbingo.org/get?api_key=secret
INFO -- : response: {
  "args": {
    "api_key": [
      "secret"
    ]
  },
  "url": "http://httpbingo.org/get?api_key=secret"
}

We’re also exposing all the sensitive attributes on user, even though those are filtered internally.

INFO -- : request: POST http://httpbingo.org/anything
INFO -- : response: {
  "data": "{\"user\":{\"id\":980190962,\"email_address\":\"one@example.com\",\"password_digest\":\"$2a$12$Qo1yNHtJ58InjxM2d3895emekpMVpEzwLTtMJ/piHeDet0oePuKne\",\"created_at\":\"2025-04-23T14:10:49.414Z\",\"updated_at\":\"2025-04-23T14:10:49.414Z\",\"phone_number\":\"555-555-5555\"}}",
  "json": {
    "user": {
      "created_at": "2025-04-23T14:10:49.414Z",
      "email_address": "one@example.com",
      "id": 980190962,
      "password_digest": "$2a$12$Qo1yNHtJ58InjxM2d3895emekpMVpEzwLTtMJ/piHeDet0oePuKne",
      "phone_number": "555-555-5555",
      "updated_at": "2025-04-23T14:10:49.414Z"
    }
  }
}

Faraday offers an API for filtering sensitive information, but using it would mean you would need to duplicate efforts.

Fortunately, we can create a custom formatter to re-use our Rails configuration.

class ApplicationFormatter < Faraday::Logging::Formatter
  def request(env)
    info("Request") { log_url(env.url) }
    info("Request") { log_body(env.body) } if env.body && log_body?
  end

  def response(env)
    info("Response") { log_url(env.url) }
    info("Response") { log_body(env.body) } if env.body && log_body?
  end

  private

  # Re-uses existing configuration from config/initializers/filter_parameter_logging.rb  
  def filter_parameters
    @filter_parameters ||= Rails.configuration.filter_parameters
  end

  # Filters parameters
  def parameter_filter(**options)
    ActiveSupport::ParameterFilter.new(filter_parameters, **options)
  end

  def parse_json(json)
    JSON.parse(json, object_class: HashWithIndifferentAccess)
  end

  def log_body?
    @options[:bodies]
  end

  def log_body(body)
    result = walk(body)

    parameter_filter.filter(result).pretty_inspect
  end

  def log_url(url)
    filtered_url = filter_url(url)

    filtered_url.to_s
  end

  def filter_url(url)
    return url if url.query.nil?

    params = URI.decode_www_form(url.query).to_h
    filtered_params = parameter_filter(mask: "FILTERED").filter(params)
    url.query = URI.encode_www_form(filtered_params)
  end

  def walk(obj)
    case obj
    when Hash
      obj.transform_values { walk(_1) }
    when Array
      obj.map { walk(_1) }
    when String
      parse_json(obj)
    else
      obj
    end
  rescue JSON::ParserError
    obj
  end
end
--- a/lib/faraday.rb
+++ b/lib/faraday.rb
-   errors: true
+   errors: true,
+   formatter: ApplicationFormatter
+ }
end

Now the api_key is filtered when logging both the response and the request. This is because we’re already filtering against partial matches on _key.

INFO -- Request: api_key=FILTERED
INFO -- Response: {"args"=>{"api_key"=>"FILTERED"},
 "url"=>"http://httpbingo.org/get?api_key=FILTERED"}

We’re also no longer exposing all the sensitive attributes on user.

INFO -- Request: http://httpbingo.org/anything
INFO -- Response: {"args"=>{},
 "data"=>
  {"user"=>
    {"id"=>980190962,
     "email_address"=>"[FILTERED]",
     "password_digest"=>"[FILTERED]",
     "created_at"=>"2025-04-23T14:10:49.414Z",
     "updated_at"=>"2025-04-23T14:10:49.414Z",
     "phone_number"=>"[FILTERED]"}},
 "json"=>
  {"user"=>
    {"created_at"=>"2025-04-23T14:10:49.414Z",
     "email_address"=>"[FILTERED]",
     "id"=>980190962,
     "password_digest"=>"[FILTERED]",
     "phone_number"=>"[FILTERED]",
     "updated_at"=>"2025-04-23T14:10:49.414Z"}}}

Creating an allow list

Let’s imagine we add a name column to the users table. Depending on the application, this could be considered sensitive information, but may not warrant encryption.

In this case, you’d need to remember to update config/initializers/filter_parameter_logging.rb. In my experience, this is almost always forgotten. Instead, what we want is an allow list.

The idea is that you’d filter everything except timestamps and IDs.

# config/initializers/filter_parameter_logging.rb

Rails.application.config.filter_parameters += [
  lambda { |k, v| v.replace("[FILTERED]") unless k.match?(/\A(id|.*_id|.*_at|.*_on)\z/) }
]

This can be confirmed when inspecting a user. Note how the name is also filtered.

#<User:0x00000001306db560
 id: 980190962,
 email_address: [FILTERED],
 password_digest: [FILTERED],
 created_at: "2025-04-23 14:10:49.414784000 +0000",
 updated_at: "2025-06-06 11:45:52.243742000 +0000",
 phone_number: "[FILTERED]",
 name: [FILTERED]>

However, this approach might be a little too aggressive, since it filters everything. Notice how the commit parameter is now filtered from our requests.

Parameters: {"authenticity_token"=>"[FILTERED]", "user"=>{"email_address"=>"[FILTERED]", "phone_number"=>"[FILTERED]", "password_digest"=>"[FILTERED]"}, "commit"=>"[FILTERED]"}

This change also affects our Faraday logging.

Now the entire url is filtered, instead of just the api_key parameter.

INFO -- Request: api_key=%5BFILTERED%5D
INFO -- Response: {"args"=>{"api_key"=>["[FILTERED]"]},
 "url"=>"[FILTERED]"}

And the entire data hash is filtered, instead of just the relevant attributes.

INFO -- Request: http://httpbingo.org/anything
INFO -- Response: {"args"=>{},
 "data"=>"[FILTERED]",
 "json"=>
  {"user"=>
    {"created_at"=>"2025-04-23T14:10:49.414Z",
     "email_address"=>"[FILTERED]",
     "id"=>980190962,
     "name"=>"[FILTERED]",
     "password_digest"=>"[FILTERED]",
     "phone_number"=>"[FILTERED]",
     "updated_at"=>"2025-06-06T11:45:52.243Z"}}}

Depending on your team’s security requirements, this might be desirable, but it can create a poor debugging experience.

Wrapping Up

The Rails defaults are a good foundation, and will serve you well. If you need to store sensitive information, make sure to encrypt it. This not only filters it from logs, but also keeps the data secure in the database.

All that aside, it’s still your responsibility to filter sensitive information from logs when using external APIs, services, and tools.

Finally, using an Allow List might be a better option for applications that need to adhere to strict compliance measures, such as Healthcare.