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.