How to keep secrets out of your logs
Short answer: A #[derive(Debug)] on a struct that holds a credential prints the credential, verbatim, the first time {:?} on that struct reaches a log line. Give every secret field a wrapper type whose Debug writes [REDACTED], and derive Debug as usual on everything that contains it. The wrapper deliberately has no Display, which turns “print the token with {}” into a compile error instead of a leak — and a small hint() method covers the one message that legitimately needs part of the value.
The previous article ended with a warning: once tokens and passwords start living in a config struct, the file on disk is not the only place they surface. This page is about the other place. Logs travel further than almost anything else a program produces — they land in a terminal, a file, a container driver, a hosted service with its own access rules — and the most common way a credential gets into all of them at once is a debug print that meant no harm.
The debug print that leaked a token
Section titled “The debug print that leaked a token”Start from a config struct holding two credentials, built the same way as the settings structs in the last two articles:
#[derive(Debug)]struct Config { db_url: String, api_token: String,}
fn main() { let config = Config { db_url: "postgres://app:hunter2@db.internal/prod".to_string(), api_token: "sk-live-9f3a72c1d8b4e6f0".to_string(), }; println!("config loaded, starting up"); println!("{config:?}");}config loaded, starting upConfig { db_url: "postgres://app:hunter2@db.internal/prod", api_token: "sk-live-9f3a72c1d8b4e6f0" }Nothing exotic happened. {:?} on a struct is the standard way to dump state at startup, and #[derive(Debug)] prints every field — including the one holding the API token, and the database URL, which carries its own password between the : and the @. One line, and the token is on the same stderr stream the log framework writes to, formatted however the log framework formats everything else. Whoever reads that log later — a teammate, a support tool, a log aggregator — reads the token.
The rest of this page fixes it in three steps: redact one struct by hand, then move the redaction into a type so it applies everywhere, then let the compiler catch the print that slips past both.
Redacting one struct by hand
Section titled “Redacting one struct by hand”The direct fix is a manual Debug implementation that formats the harmless fields normally and replaces the secret one:
use std::fmt;
struct Config { db_url: String, api_token: String,}
impl fmt::Debug for Config { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Config") .field("db_url", &self.db_url) .field("api_token", &"[REDACTED]") .finish() }}
fn main() { let config = Config { db_url: "postgres://app:hunter2@db.internal/prod".to_string(), api_token: "sk-live-9f3a72c1d8b4e6f0".to_string(), }; println!("{config:?}");}Config { db_url: "postgres://app:hunter2@db.internal/prod", api_token: "[REDACTED]" }debug_struct produces exactly the shape #[derive(Debug)] would have — same name, same field: value layout — so a log reader cannot tell the difference, and {:#?} (the pretty, multi-line form) goes through the same trait and gets the same redaction. The token is gone.
Two problems are still visible in that output. The database URL still contains hunter2, because a secret does not stop being a secret by sitting in a field named something other than token. And the fix scales badly: every struct that touches a credential needs its own hand-written Debug, and the one that gets forgotten puts the leak back. Both problems have the same answer — stop redacting structs, and redact the secret itself.
A Secret type that redacts itself
Section titled “A Secret type that redacts itself”Wrap the credential in a newtype whose Debug writes [REDACTED], and the structs that contain it can go back to #[derive(Debug)]:
use std::fmt;
struct Secret(String);
impl Secret { /// Last four characters, for "which token is this" messages. fn hint(&self) -> String { let tail: String = self.0.chars().rev().take(4).collect(); format!("...{}", tail.chars().rev().collect::<String>()) }}
impl fmt::Debug for Secret { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "[REDACTED]") }}
#[derive(Debug)]struct Config { host: String, api_token: Secret,}
fn main() { let config = Config { host: "db.internal".to_string(), api_token: Secret("sk-live-9f3a72c1d8b4e6f0".to_string()), }; println!("{config:?}"); println!("using token {}", config.api_token.hint());}Config { host: "db.internal", api_token: [REDACTED] }using token ...e6f0The derived Config debug output is now safe without anyone writing a per-struct redaction, because the derive formats each field through that field’s own Debug — and the token field’s Debug is the wrapper’s, which prints the six characters [REDACTED] and nothing else. The wrapper controls the exact bytes, so the token never reaches the output, not even inside quotes.
The tuple field .0 is private to the module that declares Secret, so code in other modules cannot reach the raw string by accident; the value gets out only through a method the wrapper offers. hint() is that method for the one legitimate case: a log line that says which credential is in use, the way a payment card statement shows the last four digits. Knowing ...e6f0 matches the token against a dashboard entry; it does not authenticate anything.
That leaves {}, the other formatter. It is the interesting one, because it does not run at all.
The accidental print that does not compile
Section titled “The accidental print that does not compile”Secret implements Debug and nothing else. Try to format it with {}, which needs Display:
use std::fmt;
struct Secret(String);
impl fmt::Debug for Secret { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "[REDACTED]") }}
fn main() { let token = Secret("sk-live-9f3a72c1d8b4e6f0".to_string()); println!("token = {token}");}error[E0277]: `Secret` doesn't implement `std::fmt::Display` --> src/main.rs:13:23 |13 | println!("token = {token}"); | ^^^^^^^ `Secret` cannot be formatted with the default formatter |help: the trait `std::fmt::Display` is not implemented for `Secret` --> src/main.rs:3:1 | 3 | struct Secret(String); | ^^^^^^^^^^^^^ = note: in format strings you may be able to use `{:?}` (or `{:#?}` for pretty-print) instead
For more information about this error, try `rustc --explain E0277`.This is the payoff of leaving Display out. A plain-string credential prints happily with {}, so the slip — pasting a token into a message with {} instead of {:?} — compiles and ships. With the wrapper, the same slip is a build failure, and the compiler’s own suggestion (use {:?} instead) points at the formatter that redacts. After that, the only ways to render a Secret are {:?}, which is safe, and a method like hint(), which was written deliberately. Nobody has to review log lines for tokens anymore; the type system does it.
What this does not fix
Section titled “What this does not fix”Three limits are worth writing down, because each one has caught someone.
A string that escaped the wrapper. config.api_token.0 is a String, and once a &str of the real token is interpolated into a message by hand — format!("auth {t}") — the wrapper cannot follow it. Keep the .0 accesses in one small module and treat that module as the only place that has seen the value; everywhere else works with the wrapper and its methods.
The copy that is already out. Redaction changes what future log lines contain. The line from the first example is already on disk, and logs are append-only by nature: the fix for a leaked token is rotating it, and the fix this page describes is for every run after that.
Serialisers that bypass Debug. A structured logger that renders a value through Display or Debug gets the redaction for free, because those are the only two entry points it has. A logger that serialises the struct straight to JSON reads fields through Serialize instead, and the derived implementation writes the raw string. If logs go to a JSON pipeline, give Secret a Serialize implementation with the same output as its Debug.
One more thing std cannot help with: the token also lives in memory as an ordinary String until the allocator reuses those bytes, and std has no zero-on-drop support. Logging is where credentials leak in practice, though — and with the wrapper, the leak requires calling a method that does not exist.