Prevent logging sensitive information in Rails, and beyond

Ask questions Research chat →

https://thoughtbot.com/blog/parameter-filtering · scraped

rails

Attachments

Scraped Content

— 967 words · 2026-05-19 12:36:01 UTC ·

Excerpt

![](https://prod-files-secure.s3.us-west-2.amazonaws.com/871f1661-80b8-4d0c-ac3b-2adfc6ff4c66/8ac0ecf0-2c92-4c0d-abd6-a17ed3125e08/aEccJrh8WN-LV5_m_default-article-background.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB4665ERZMHZX%2F20260519%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260519T193600Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEBMaCXVzLXdlc3QtMiJHMEUCIA9ymIEkL9YNpSj3dNzDzyrzG6T6xoraFCzcEVsEefsOAiEA8%2BMqpdQCyyOU0UsVr4u3nHnGPkLBUzrzI46q9d9fgYoqiAQI3P%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FARAAGgw2Mzc0MjMxODM4MDUiDPeF73%2Bc6m7H%2BtoKOSrcA06RTaWyF3IkjQoDNxvbM%2Fj4pCfzSiD3tQ6M16%2Flm0LnQbSObxLcfN19M3nejjdm%2BvjeEklJeLYBTny8nUR8tVhMRwkhb8tGzjDu%2Fd59K1SR4TKubWFgsvyw1TVamGeWjrjrTXHaOogfi4vYDjNTMYC41Inxih1jW1SanTy%2Fylwg3vuFFK6BNkgd0rPIKCRkmWvRO2ksBu0GyPsdk1%2Bu8l0JquGF5wWomUGNAGRVqzFFQ1J%2Ba10zBEMiZ16VjGAOHOfsIQAtHTuB%2Byx7lD9hxeLHHdvUHG64omGfBpqWfade3DK4zZdS3Nsup%2BHrFzl29U2MbkSbzoFYeZihODTRHHh2n4bppggTVtdPL725PbPfW
![](https://prod-files-secure.s3.us-west-2.amazonaws.com/871f1661-80b8-4d0c-ac3b-2adfc6ff4c66/8ac0ecf0-2c92-4c0d-abd6-a17ed3125e08/aEccJrh8WN-LV5_m_default-article-background.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB4665ERZMHZX%2F20260519%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260519T193600Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEBMaCXVzLXdlc3QtMiJHMEUCIA9ymIEkL9YNpSj3dNzDzyrzG6T6xoraFCzcEVsEefsOAiEA8%2BMqpdQCyyOU0UsVr4u3nHnGPkLBUzrzI46q9d9fgYoqiAQI3P%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FARAAGgw2Mzc0MjMxODM4MDUiDPeF73%2Bc6m7H%2BtoKOSrcA06RTaWyF3IkjQoDNxvbM%2Fj4pCfzSiD3tQ6M16%2Flm0LnQbSObxLcfN19M3nejjdm%2BvjeEklJeLYBTny8nUR8tVhMRwkhb8tGzjDu%2Fd59K1SR4TKubWFgsvyw1TVamGeWjrjrTXHaOogfi4vYDjNTMYC41Inxih1jW1SanTy%2Fylwg3vuFFK6BNkgd0rPIKCRkmWvRO2ksBu0GyPsdk1%2Bu8l0JquGF5wWomUGNAGRVqzFFQ1J%2Ba10zBEMiZ16VjGAOHOfsIQAtHTuB%2Byx7lD9hxeLHHdvUHG64omGfBpqWfade3DK4zZdS3Nsup%2BHrFzl29U2MbkSbzoFYeZihODTRHHh2n4bppggTVtdPL725PbPfWQtb7%2BjDRsVGGluuzBekYuixSoSuFDgo2tlQa%2B8TDN3rHcsX7%2Fknw%2FH7jB8HgB%2BI8zfacVhAb7DTEoiy5oJfkQASl4eRnctnfOpZsS7Lx7yje38KnhVv0v5Ac4IZmw2cUoXS02J04bntAMjVCrlvMO7udrHipUfiya2yr8wjcNR9%2BrF5vnO8DwZtgKU9j9NTLwLAdNSqE3VTzSXEUizR%2B8hGfk71FPPN6txRzCbDWMAUtXPY9CgBc%2B7pRJFaSA1Ssudsu%2FabnRLznvRFMPLastAGOqUBT6F0kRFYbr5gkU3FtXMFHdn5BVDWQlDrsOZ3O8oGRFOFDxaLVah7dMe58brEJc9CbYQ7ApEL9%2FcBvEbrmRJU0M9nSmV1rGmdPHdD6qsfstRYjeUiY8AqI8G44JITGNW1XpNOKDdWOqB2erBiOLjyDzf79PBsJRuV9QJsd%2FUkSrxNmzx9R%2BohYnWqhhtpVCYcl03mnY8S7GGsbIvpVgr5SqnhBvCs&X-Amz-Signature=c03cf9f4d86a748e1ba45aee156c5abe426f9b09e80ca858c84577c394674a75&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject) 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. ```plain text # 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. ```plain text Parameters: {"authenticity_token"=>"[FILTERED]", "email_address"=>"[FILTERED]", "password"=>"[FILTERED]", "commit"=>"Sign in"} ``` It also filters the associated attributes. ```plain text <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"> ``` 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. ```plain text 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. ```plain text 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. ```plain text <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]"> ``` 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. ```plain text 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. ```plain text 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. ```plain text 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. ```plain text 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 ``` ```plain text --- 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. ```plain text 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. ```plain text 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"}}} ``` 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. ```plain text # 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. ```plain text #<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. ```plain text 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. ```plain text 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. ```plain text 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. 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. ## About thoughtbot We've been helping engineering teams deliver exceptional products for over 20 years. Our designers, developers, and product managers work closely with teams to solve your toughest software challenges through collaborative design and development. Learn more about us.

Visibility

Visible to everyone

Reading Status

Related Bookmarks

My Note


Saved!

Annotations

Export as Markdown
+ Annotate selection

Add Annotation