Filtering Sensitive Data

LogStruct keeps sensitive values out of the lines it writes in two ways: it filters values by the names of their keys, and it scrubs data in known formats, such as email addresses and card numbers, out of every string. Both run in LogStruct's formatter, on every line it writes while it's on (see When LogStruct Runs). Both match names and patterns, so they can miss sensitive data, and they can replace data that isn't sensitive.

Filtering by Key

When a key's name is sensitive, LogStruct replaces its value with a summary. This applies at any depth: in request params, job arguments, Hashes you log and Arrays of Hashes.

What a Filtered Value Looks Like

Rails.logger.info(
  msg: "Password changed",
  user_id: 1873,
  email: "Linda.Martinez@example.com",
  password: "correct horse battery staple",
  otp: 482113,
  api_tokens: ["tok_8c1f2a", "tok_93d0b7"],
  credentials: {client_id: "my-app", client_secret: "abc123"}
)

LogStruct writes:

{
  "msg": "Password changed",
  "user_id": 1873,
  "email": {
    "_filtered": {
      "_class": "String",
      "_hash": "a058d5090035d2e1"
    }
  },
  "password": {
    "_filtered": {
      "_class": "String"
    }
  },
  "otp": {
    "_filtered": {
      "_class": "Integer"
    }
  },
  "api_tokens": {
    "_filtered": {
      "_class": "Array",
      "_count": 2
    }
  },
  "credentials": {
    "_filtered": {
      "_class": "Hash",
      "_keys_count": 2,
      "_keys": ["client_id", "client_secret"]
    }
  },
  "src": "app",
  "evt": "log",
  "ts": "2026-10-17T09:30:15.123+00:00",
  "lvl": "info",
  "prog": "Rails"
}

The summary depends on the value:

  • String: {"_class": "String"}. For a key in filter_keys_with_hashes, a _hash of the value too (see Hashed Values).
  • Hash: its class, how many keys it has (_keys_count) and its first 10 keys (_keys), scrubbed like any string. An empty Hash is {"_class": "Hash", "_empty": true}.
  • Array: its class and how many items it has (_count). An empty Array is {"_class": "Array", "_empty": true}.
  • Anything else (a number, nil, true): its class only.

Summaries have no lengths or sizes, which would say how long a password or token is, and no value inside a filtered Hash or Array is written. A value that is already "[FILTERED]" (Rails filtered it) is written as it is, with no summary.

Which Keys Are Filtered

A key is filtered when any of these match it:

  1. filter_keys, by default password, password_confirmation, pass, pw, token, secret, credentials, auth, authentication, authorization, credit_card, ssn, social_security, and filter_keys_with_hashes, by default email, email_address. The whole key has to match an entry: LogStruct compares the key in lowercase, and the key's words joined by underscores. So :credit_card filters creditCard, Credit-Card and CREDIT_CARD, but not credit_card_number (the default pattern filters that one). An entry is a name, not a pattern: :_key only filters a key named _key.
  2. The default pattern, which matches whole words in a key (its words joined by underscores, so apiKey, X-Api-Key and HTTP_X_API_KEY all contain api_key):
    • anywhere in the key: password, passwd, passphrase, passcode, pwd, token, secret, auth, authorization, authentication, cred, credential, cookie, session, jwt, bearer, otp, apikey, cvv, cvc, ssn, social_security, social_security_number, credit_card, card_number, crypt and crypted, most with their plurals (passwords, tokens)
    • anywhere in the key: api_key, secret_key, private_key, access_key, encryption_key, signing_key and master_key, and their plurals
    • as the last word: signature, sig, salt and certificate, and their plurals
  3. Rails' filter_parameters, matched as Rails matches them (see Rails filter_parameters).
  4. Your own rules in filter_matchers and path_matchers (see Adding Keys).

Filtered by the defaults: apiKey, X-Api-Key, HTTP_AUTHORIZATION, access_token, client_secret, newPassword, encrypted_password, passcode, Set-Cookie, session_id, otp_code, cvv, card_number, credit_card_number, social_security_number, private_key, X-Amz-Signature, password_salt.

Not filtered by the defaults: author, keyboard, passport, passwordless, secretary, key, cron_key, primary_key, signature_field_id, certificate_id, pin, account_number, username, phone, date_of_birth. Their values are still scrubbed like any string.

Because the pattern matches a word anywhere in a key, it also filters keys that aren't secrets, such as token_count, session_count, auth_provider, cookie_consent.

Rails filter_parameters

After your app's config/initializers run, LogStruct adds the entries in Rails.application.config.filter_parameters to its own rules, and matches them the way Rails does:

  • A Symbol or String matches any key that contains it, ignoring case (:passw filters user_password). A Regexp is matched as it is.
  • An entry with a dot ("credit_card.code", or a Regexp with \.) matches a nested key by its path. A request line's params match from the params down, as in Rails.
  • A filter block gets copies of the key and value, as in Rails. LogStruct filters the key when the block returns a truthy value or changes the String it was given. A block that takes three arguments gets the parameters around the key, with indifferent access: the value of the line's top-level field (a request line's params), or the line itself for a top-level key.
  • The partial matches of Symbols and Strings don't apply to the names of LogStruct's own fields at the top of a line, so :_key doesn't filter a GoodJob line's cron_key. Exact names, Regexps and blocks do.

A new Rails 8 app filters passw, email, secret, token, _key, crypt, salt, certificate, otp, ssn, cvv, cvc (in config/initializers/filter_parameter_logging.rb). As partial matches, they filter keys the defaults leave, some of them not secrets: primary_key, foreign_key, user_email, email_verified, passwordless, decrypt, footprint (:otp matches footprint).

Rails filters a request's params before LogStruct gets them, so in a request line a key Rails filters is "[FILTERED]", with no summary and no hash. A sign-up request's params, with Rails 8's list:

"params": {
  "user": {
    "email": "[FILTERED]",
    "password": "[FILTERED]",
    "name": "Linda",
    "api_key_id": "[FILTERED]"
  }
}

Values Rails doesn't filter first, such as job arguments and the Hashes you log, get LogStruct's summaries. LogStruct doesn't change Rails' list, so Rails' own filtering keeps working as before.

Hashed Values

Keys in filter_keys_with_hashes (by default email and email_address) are filtered like the others, and the summary of a String value has a _hash of it. Every email address in a string becomes [EMAIL:hash], with the same hash. So you can find the lines about one address without logging it:

Rails.logger.info(
  msg: "Sent a password reset link to Linda.Martinez@example.com",
  email: "linda.martinez@example.com",
  reply_to: "support@example.com"
)
{
  "msg": "Sent a password reset link to [EMAIL:a058d5090035d2e1]",
  "email": {
    "_filtered": {
      "_class": "String",
      "_hash": "a058d5090035d2e1"
    }
  },
  "reply_to": "[EMAIL:3cfed1fb42ca3f06]",
  "src": "app",
  "evt": "log",
  "ts": "2026-10-17T09:30:15.123+00:00",
  "lvl": "info",
  "prog": "Rails"
}

reply_to isn't a filtered key, so its value is scrubbed like any string. An email address is hashed in lowercase, so it has one hash however it's capitalized. Other values are hashed as they are: JDoe and jdoe get different hashes.

The hash is an HMAC-SHA256 of the value, cut to hash_length hex characters (16 by default). Its key is hash_salt. Without it, LogStruct derives a key from your app's secret_key_base, so the hashes differ from one app to the next, and they all change when you change secret_key_base. Set hash_salt to keep them when you rotate it, or to get the same hashes in several apps:

LogStruct.configure do |config|
  # The key for the hashes. Without it, LogStruct derives one from secret_key_base.
  config.filters.hash_salt = ENV.fetch("LOGSTRUCT_HASH_KEY")
end

Outside a Rails app, without hash_salt, LogStruct uses a fixed key that is public in its source code.

Hashes are pseudonyms, not anonymous data: anyone who has the key and a list of email addresses can work out which hash belongs to which address. Keep the key secret.

Adding Keys

LogStruct.configure do |config|
  # Filter these keys too. Keys match by their words, so :date_of_birth also filters
  # dateOfBirth and Date-Of-Birth, but not date_of_birth_verified.
  config.filters.filter_keys += [:date_of_birth, :pin]

  # Filter these keys and log a hash of their String values, as for email
  config.filters.filter_keys_with_hashes += [:phone]

  # A rule for key names: it gets each key as a String, and its value
  config.filters.filter_matchers << LogStruct::ConfigStruct::FilterMatcher.new(
    callable: ->(key, _value) { key.end_with?("_iban") },
    label: "IBANs"
  )
end

Add to the lists with += and <<, as above. Assigning a list replaces the defaults: config.filters.filter_keys = [:pin] stops filtering password and token by name, and assigning filter_matchers removes the default pattern. Keys you add to Rails' filter_parameters work too, and Rails filters them in its own request params. Every setting, with its default, is in Configuration.

If a key or path filter raises, LogStruct handles the error using error_handling_modes.logstruct_errors. When that mode lets logging continue, the value is filtered. A failing filter never counts as permission to write the value.

String Scrubbing

LogStruct scans every String it writes for data in known formats and replaces it, whatever key it's under: messages, values at any depth, Symbols, GlobalIDs, Hash keys that aren't plain identifiers (an email address used as a key), and the keys in a filtered Hash's summary. Tags, the request ID and the job context are scrubbed too. Numbers aren't: a card number logged as an Integer is written as it is, unless its key is filtered.

The scrubbers are based on ankane's logstop gem. Each has a setting in config.filters, and they run in this order:

  • authorization_credentials (on by default): The credentials after Bearer or Basic (in any case), as in an Authorization header logged as text: a Bearer token of 16 or more characters, or Basic credentials that decode from Base64 to user:password. Replaced with [FILTERED].
  • url_query_params (on by default): In a URL's query string (after a ?), the value of each param whose name would be filtered as a key, and of OAuth's code. A name like user[password] is checked by each part, written plainly or URL-encoded, so password[] and token[value] are filtered too. Dotted Rails filters match the nested path. Replaced with [FILTERED].
  • url_passwords (on by default): The password in a URL's user info (scheme://user:password@host), also in URL-encoded URLs. Replaced with [PASSWORD].
  • email_addresses (on by default): Email addresses, with letters and digits of any script and a top-level domain of two or more letters. Replaced with [EMAIL:hash] (see Hashed Values).
  • credit_card_numbers (on by default): 13 to 19 digits starting with 2 to 6: written together, in groups of four separated by spaces or dashes, or in American Express and Diners Club groups. The number has to pass the Luhn check that every card number passes, so most IDs and timestamps are left alone. Replaced with [CREDIT_CARD].
  • phone_numbers (on by default): US phone numbers written with separators: 555-123-4567, 555 123 4567, 555.123.4567 and (555) 123-4567, with or without +1 or 1. Replaced with [PHONE].
  • ssns (on by default): US social security numbers written with dashes or spaces (123-45-6789), and not part of a longer number. Replaced with [SSN].
  • ip_addresses (off by default): IPv4 addresses (four numbers of 1 to 3 digits, separated by dots) and IPv6 addresses (text that Ruby's IPAddr reads as one and that has a digit). Replaced with [IP].
  • mac_addresses (off by default): Six pairs of hex digits separated by colons or by dashes. Replaced with [MAC].

What each one writes (the hashes depend on your key):

SettingLoggedWritten
authorization_credentials
On by default
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcAuthorization: Bearer [FILTERED]
Authorization: Basic dXNlcjpwYXNzd29yZA==Authorization: Basic [FILTERED]
Bearer tokenBearer token
Fewer than 16 characters
Basic planBasic plan
Not Base64 of user:password
url_query_params
On by default
GET /reset?token=abc123&locale=enGET /reset?token=[FILTERED]&locale=en
/signup?user[password]=hunter2&user[name]=Jo/signup?user[password]=[FILTERED]&user[name]=Jo
/oauth/callback?code=4/0AY0e&state=xyz/oauth/callback?code=[FILTERED]&state=xyz
/login?password[]=hunter2/login?password[]=[FILTERED]
/login?token[value]=abc123/login?token[value]=[FILTERED]
password=hunter2&user=jopassword=hunter2&user=jo
No ? before the params
url_passwords
On by default
postgres://app:s3cret@db.internal:5432/apppostgres://app:[PASSWORD]@db.internal:5432/app
redis://:s3cret@cache:6379/0redis://:[PASSWORD]@cache:6379/0
https%3A%2F%2Fuser%3As3cret%40example.comhttps%3A%2F%2Fuser%3A[PASSWORD]%40example.com
email_addresses
On by default
Sent to Linda.Martinez@example.comSent to [EMAIL:a058d5090035d2e1]
Sent to linda.martinez@example.comSent to [EMAIL:a058d5090035d2e1]
Same hash
José.Núñez@correo.es[EMAIL:c28bb257abd35f9c]
deploy@localhostdeploy@localhost
No top-level domain
credit_card_numbers
On by default
Paid with 4111 1111 1111 1111Paid with [CREDIT_CARD]
Paid with 4111-1111-1111-1111Paid with [CREDIT_CARD]
Paid with 4111111111111111Paid with [CREDIT_CARD]
Paid with 3782 822463 10005Paid with [CREDIT_CARD]
Paid with 4111 1111 1111 1112Paid with 4111 1111 1111 1112
Fails the Luhn check
Order 2026101712345679Order [CREDIT_CARD]
Not a card, but passes the Luhn check
phone_numbers
On by default
Call 555-123-4567Call [PHONE]
Call 555.123.4567Call [PHONE]
Call (555) 123-4567Call [PHONE]
Call +1 555 123 4567Call [PHONE]
Call 1-555-123-4567Call [PHONE]
Call 5551234567Call 5551234567
No separators
Call +44 20 7946 0958Call +44 20 7946 0958
Not a US number
Reference 100-200-3000Reference [PHONE]
Not a phone number
ssns
On by default
SSN 123-45-6789SSN [SSN]
SSN 123 45 6789SSN [SSN]
Part 123-45-6789-0Part 123-45-6789-0
Part of a longer number
SSN 123456789SSN 123456789
No separators
ip_addresses
Off by default
From 192.168.1.10From [IP]
From 2001:db8::1From [IP]
From ::ffff:192.168.1.1From [IP]
From fe80::1%en0From [IP]
Version 1.2.3.4Version [IP]
Not an IP address
At 12:30:45At 12:30:45
A time
mac_addresses
Off by default
MAC 00:1A:2B:3C:4D:5EMAC [MAC]
MAC 00-1A-2B-3C-4D-5EMAC [MAC]
MAC 001A.2B3C.4D5EMAC 001A.2B3C.4D5E
Dotted groups of four

What Scrubbing Misses

The scrubbers look for formats, not meaning. They miss sensitive data in other formats, and they replace text that only looks like it:

  • Numbers (Integers and Floats) aren't scrubbed.
  • Formats the scrubbers don't know are left: phone numbers outside the US, phone numbers and SSNs without separators, names and street addresses, and secrets in plain text, such as password=hunter2 outside a URL's query string or an API key like sk_live_... in a message.
  • Text that only looks sensitive is replaced: a reference written like a phone number, an ID that passes the Luhn check, a version number like 1.2.3.4 (with ip_addresses on), and a URL's user name and host (https://user@example.com), which look like an email address.

Keep secrets out of what you log where you can. Filtering catches the common names and formats, not everything.

Custom String Scrubbing

For formats the built-in scrubbers don't know, set config.string_scrubbing_handler. It gets every string after the built-in scrubbers, Hash keys that aren't plain identifiers included, and returns the string to write. See Custom String Scrubbing in Configuration.

Where Filtering Applies

  • LogStruct's lines: every line LogStruct's appenders write (to standard output, and in development and test to Rails' log file) goes through its formatter, whatever logged it: your code, Rails and the integrations. In development with prefer_json_in_development off, the colored lines are filtered and scrubbed the same way.
  • Appenders you add to SemanticLogger yourself write with the formatter you give them. SemanticLogger's own formatters don't filter anything: pass formatter: LogStruct::SemanticLogger::Formatter.new to filter their lines too.
  • Loggers added with broadcast_to get String messages (and what a block returns, and lines written with <<) scrubbed, as text. Hashes and LogStruct entries aren't passed to them.
  • Error reports: LogStruct passes the exception itself to Sentry, Bugsnag, Rollbar, Honeybadger or your error_reporting_handler. Its message and backtrace aren't scrubbed, so an email address or card number in an exception's message reaches your error tracker. The context LogStruct sends with it is filtered and scrubbed like a log line, and so is the error line LogStruct writes. Use your error tracker's own filtering for what it receives.
  • SQL bind values (with enable_sql_logging on, and sql_log_bind_params, which is on unless the Rails environment is production): a value whose column name would be filtered as a key is written as "[FILTERED]", with no summary or hash. So is a String value that looks like a secret: longer than 50 characters with no spaces, only hex digits (32 or more) or Base64 characters (20 or more), a JWT, or a Bearer token. Binary values are written as their size in bytes. Other values are scrubbed like any string. The SQL itself is scrubbed but not filtered: a value written into it (a string condition, or any value with prepared_statements off) is only scrubbed. See SQL Queries.
  • The log.logstruct notification: LogStruct sends it for each LogStruct entry or T::Struct logged through its logger (not for Hashes or Strings), with the entry as it was logged, before filtering. If a subscriber sends it anywhere, filter it there.
  • While LogStruct is off, none of this applies: Rails writes its own lines, filtered only by its filter_parameters.

What the Integrations Leave Out

  • Referer query strings: request lines, blocked host lines, security and error lines, and the context sent with security errors have the Referer without its query string, which can hold a password reset, confirmation or sign-in token.
  • ActiveStorage URLs: url lines have the service and the file's key, but not the signed URL, which lets anyone read the file until it expires.
  • File names: ActiveStorage and Shrine lines have where a file is stored (its key or location), not the name it was uploaded with, which often names a person. Shrine's upload options are logged with the file names taken out of a content_disposition. A location your uploader builds from a file's name (and CarrierWave's paths, which use the uploaded name by default) is logged as it is: scrub it with a string_scrubbing_handler.
  • Mail delivery job arguments: a deliver_later job (ActionMailer::MailDeliveryJob or a subclass) is logged with only the mailer, the mail and the delivery method as its arguments, while the ActiveJob integration is on. A mail's own arguments can be secrets with no key to filter on, such as the tokens Devise passes to its confirmation and password reset emails.
  • Mail subjects are logged, scrubbed like any string. Keep one-time codes out of subjects.

Large and Nested Values

A string longer than limits.max_string_bytes is scrubbed before it's cut: the scrubbers see its first max_string_bytes plus 256 bytes, so data at the cut is replaced whole (the replacement itself can be cut). A URL password or Basic credentials that the cut runs through are replaced however long they are. A string_scrubbing_handler sees the same part of the string. Values nested more than 20 levels deep, and circular references, would skip filtering, so they're replaced with a summary like a filtered value's ({"_filtered": {"_class": "Hash", "_keys_count": 2}}). The limits on keys, items and string sizes are in Limits.