Rails.application.config.filter_parameters += [
:password,
:password_confirmation,
:secret,
:token,
:api_key,
:access_token,
:ssn,
:cvv,
/card.*number/i,
lambda do |key, value|
next unless key == "authorization" && value.is_a?(String)
value.replace("#{value[0, 8]}...[REDACTED]") if value.length > 8
end
]
module LogSafe
extend ActiveSupport::Concern
def filtered_attributes
self.class.log_filter.filter(attributes)
end
def log_line(action)
"#{self.class.name}##{action} #{filtered_attributes.inspect}"
end
module ClassMethods
def log_filter
@log_filter ||= ActiveSupport::ParameterFilter.new(
Rails.application.config.filter_parameters
)
end
end
end
class PaymentsController < ApplicationController
def create
Rails.logger.info("Received payment params: #{params.to_unsafe_h.inspect}")
payment = Payment.new(payment_params)
if payment.save
Rails.logger.info(payment.log_line(:create))
render json: { id: payment.id, status: "authorized" }, status: :created
else
render json: { errors: payment.errors.full_messages }, status: :unprocessable_entity
end
end
private
def payment_params
params.require(:payment).permit(:amount, :card_number, :cvv, :token)
end
end
require "rails_helper"
RSpec.describe "POST /payments", type: :request do
let(:logs) { StringIO.new }
before do
Rails.logger = ActiveSupport::Logger.new(logs)
end
it "never writes the raw card number or cvv to the log" do
post "/payments", params: {
payment: { amount: 4200, card_number: "4242424242424242", cvv: "123", token: "tok_live_abc" }
}
output = logs.string
expect(output).to include("[FILTERED]")
expect(output).not_to include("4242424242424242")
expect(output).not_to include("tok_live_abc")
end
end
Rails logs every request, and by default that includes the request parameters. Without filtering, passwords, tokens, and credit-card numbers land in plaintext log files and log-aggregation systems, creating a serious PII and compliance problem. Rails ships with ActionDispatch::Http::ParameterFilter (exposed through config.filter_parameters), which walks the params hash and replaces matching values with [FILTERED] before anything is written. This snippet shows how to configure it centrally and how to prove the behavior.
In config/initializers/filter_parameter_logging.rb, the filter list is assembled from three kinds of rules. Plain symbols like :password and :ssn match any key whose name contains that substring, so :password also covers password_confirmation and user[password]. A Regexp such as /card.*number/i handles keys that a simple substring cannot express. Finally, a Proc gives full control: it receives the key and a mutable value, and here it masks a raw JWT in authorization down to a short prefix. Because matching is substring-based on the flattened key path, it works recursively through nested hashes and arrays without extra code.
The LogSafe concern in app/models/concerns/log_safe.rb addresses a subtler leak: params filtering only covers the request pipeline, not values a developer explicitly logs. filtered_attributes reuses the very same Rails.application.config.filter_parameters list through a fresh ActiveSupport::ParameterFilter, so a model's own logging obeys the same policy as the framework. Reusing the configured list avoids drift between two redaction rules.
PaymentsController demonstrates the payoff: even though it logs params directly in create, the sensitive fields are already redacted, and payment.filtered_attributes is safe to log too. The spec/requests/payments_spec.rb test locks the behavior in by asserting the emitted log contains [FILTERED] and never the real secret — the kind of guard that prevents a well-meaning refactor from silently re-exposing data. The main trade-off is that overly broad rules (a bare :key) can over-redact useful debugging fields, so rules should be specific. This pattern is the baseline every production Rails app should adopt before shipping.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
Share this code
Here's the card — post it anywhere.