Excerpt

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.