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 infilter_keys_with_hashes, a_hashof 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:
filter_keys, by defaultpassword,password_confirmation,pass,pw,token,secret,credentials,auth,authentication,authorization,credit_card,ssn,social_security, andfilter_keys_with_hashes, by defaultemail,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_cardfilterscreditCard,Credit-CardandCREDIT_CARD, but notcredit_card_number(the default pattern filters that one). An entry is a name, not a pattern::_keyonly filters a key named_key.- The default pattern, which matches whole words in a key (its words joined by underscores, so
apiKey,X-Api-KeyandHTTP_X_API_KEYall containapi_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
- Rails'
filter_parameters, matched as Rails matches them (see Rails filter_parameters). - Your own rules in
filter_matchersandpath_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 (
:passwfiltersuser_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
:_keydoesn't filter a GoodJob line'scron_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")
endOutside a Rails app, without hash_salt, LogStruct uses a fixed key that is public in its source code.
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"
)
endAdd 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 afterBearerorBasic(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 touser: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'scode. A name likeuser[password]is checked by each part, written plainly or URL-encoded, sopassword[]andtoken[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.4567and(555) 123-4567, with or without+1or1. 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'sIPAddrreads 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):
| Setting | Logged | Written |
|---|---|---|
| authorization_credentials On by default | Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc | Authorization: Bearer [FILTERED] |
| Authorization: Basic dXNlcjpwYXNzd29yZA== | Authorization: Basic [FILTERED] | |
| Bearer token | Bearer token Fewer than 16 characters | |
| Basic plan | Basic plan Not Base64 of user:password | |
| url_query_params On by default | GET /reset?token=abc123&locale=en | GET /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=jo | password=hunter2&user=jo No ? before the params | |
| url_passwords On by default | postgres://app:s3cret@db.internal:5432/app | postgres://app:[PASSWORD]@db.internal:5432/app |
| redis://:s3cret@cache:6379/0 | redis://:[PASSWORD]@cache:6379/0 | |
| https%3A%2F%2Fuser%3As3cret%40example.com | https%3A%2F%2Fuser%3A[PASSWORD]%40example.com | |
| email_addresses On by default | Sent to Linda.Martinez@example.com | Sent to [EMAIL:a058d5090035d2e1] |
| Sent to linda.martinez@example.com | Sent to [EMAIL:a058d5090035d2e1] Same hash | |
| José.Núñez@correo.es | [EMAIL:c28bb257abd35f9c] | |
| deploy@localhost | deploy@localhost No top-level domain | |
| credit_card_numbers On by default | Paid with 4111 1111 1111 1111 | Paid with [CREDIT_CARD] |
| Paid with 4111-1111-1111-1111 | Paid with [CREDIT_CARD] | |
| Paid with 4111111111111111 | Paid with [CREDIT_CARD] | |
| Paid with 3782 822463 10005 | Paid with [CREDIT_CARD] | |
| Paid with 4111 1111 1111 1112 | Paid with 4111 1111 1111 1112 Fails the Luhn check | |
| Order 2026101712345679 | Order [CREDIT_CARD] Not a card, but passes the Luhn check | |
| phone_numbers On by default | Call 555-123-4567 | Call [PHONE] |
| Call 555.123.4567 | Call [PHONE] | |
| Call (555) 123-4567 | Call [PHONE] | |
| Call +1 555 123 4567 | Call [PHONE] | |
| Call 1-555-123-4567 | Call [PHONE] | |
| Call 5551234567 | Call 5551234567 No separators | |
| Call +44 20 7946 0958 | Call +44 20 7946 0958 Not a US number | |
| Reference 100-200-3000 | Reference [PHONE] Not a phone number | |
| ssns On by default | SSN 123-45-6789 | SSN [SSN] |
| SSN 123 45 6789 | SSN [SSN] | |
| Part 123-45-6789-0 | Part 123-45-6789-0 Part of a longer number | |
| SSN 123456789 | SSN 123456789 No separators | |
| ip_addresses Off by default | From 192.168.1.10 | From [IP] |
| From 2001:db8::1 | From [IP] | |
| From ::ffff:192.168.1.1 | From [IP] | |
| From fe80::1%en0 | From [IP] | |
| Version 1.2.3.4 | Version [IP] Not an IP address | |
| At 12:30:45 | At 12:30:45 A time | |
| mac_addresses Off by default | MAC 00:1A:2B:3C:4D:5E | MAC [MAC] |
| MAC 00-1A-2B-3C-4D-5E | MAC [MAC] | |
| MAC 001A.2B3C.4D5E | MAC 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=hunter2outside a URL's query string or an API key likesk_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(withip_addresseson), 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_developmentoff, 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.newto filter their lines too. - Loggers added with
broadcast_toget 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_loggingon, andsql_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 withprepared_statementsoff) is only scrubbed. See SQL Queries. - The
log.logstructnotification: LogStruct sends it for each LogStruct entry orT::Structlogged 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:
urllines 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 astring_scrubbing_handler. - Mail delivery job arguments: a
deliver_laterjob (ActionMailer::MailDeliveryJobor 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.