Logging Without Leaking Secrets in Rust Web Services
Logs leak more secrets than breaches: derived Debug, request headers, URLs, SQL parameters and error chains. Use secrecy, custom Debug, tracing skip and filters to stop it.

Few people write info!("password = {password}"). Secrets reach logs through formatting that nobody looked at: a derived Debug, an instrumented function, a request dump. Logs are copied to more places than databases, kept longer and read by more people, so a leaked secret there is hard to contain. Everything compiles on Rust 1.90 with edition 2024, secrecy 0.10, tracing 0.1 and serde 1.
Leak 1: derived Debug
#[derive(Debug)]
struct Credentials {
user: String,
password: String,
}
fn main() {
let c = Credentials { user: "ada".into(), password: "hunter2".into() };
println!("login attempt: {c:?}");
}login attempt: Credentials { user: "ada", password: "hunter2" }Every Debug print of a struct containing the field leaks it, including panics and error chains.
### Fix: a secret type
use secrecy::{ExposeSecret, SecretString};
#[derive(Debug)]
struct Credentials {
user: String,
password: SecretString,
}
fn main() {
let c = Credentials { user: "ada".into(), password: SecretString::from("hunter2") };
println!("login attempt: {c:?}");
let ok = c.password.expose_secret() == "hunter2";
println!("{ok}");
}login attempt: Credentials { user: "ada", password: SecretBox<str>([REDACTED]) }
trueSecretString's Debug prints a placeholder, and reading the value needs .expose_secret(), which stands out in review. Search for expose_secret to audit every place the secret is touched. Use the same type for API keys, tokens and connection strings (zeroize and secrecy).
Leak 2: instrument
use tracing::instrument;
#[instrument]
fn authenticate(user: &str, password: &str) -> bool {
!password.is_empty() && !user.is_empty()
}The span records user and password as fields. Skip them:
use tracing::instrument;
#[instrument(skip(password), fields(user = %user))]
fn authenticate(user: &str, password: &str) -> bool {
!password.is_empty() && !user.is_empty()
}
#[instrument(skip_all)]
fn rotate_keys(old: &[u8], new: &[u8]) {}skip_all is the safest default for anything handling credentials: nothing is recorded unless you add it with fields(...).
Leak 3: requests and URLs
- **Headers:** do not log
Authorization,CookieorSet-Cookie. If you usetower_http::trace::TraceLayer, its default span does not include headers, and a custom span should list the safe ones only.tower_http::sensitive_headersmarks headers as sensitive so they print asSensitiveinDebug. - **URLs:** tokens in query strings (
?token=..., signed URLs) end up in access logs and proxies. Pass them in headers or POST bodies, and log the path without the query. - **Bodies:** never log a request or response body wholesale; log selected fields.
use axum::http::{header, HeaderMap, HeaderName};
fn safe_headers(h: &HeaderMap) -> Vec<(String, String)> {
const HIDE: [HeaderName; 3] = [header::AUTHORIZATION, header::COOKIE, header::SET_COOKIE];
h.iter()
.filter(|(k, _)| !HIDE.contains(k))
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("<binary>").to_string()))
.collect()
}Leak 4: errors and SQL
Connection strings in error messages (error connecting to postgres://user:password@host/db) leak the database password. Parse connection options into a struct and log host and database only. Log SQL statements but not bound parameters at info; sqlx can log statements at debug, and parameters should not be added. Third-party error Display output can embed input values, so log errors at the boundary with care.
Test that it does not leak
#[test]
fn password_never_appears_in_logs() {
let output = capture_logs(|| {
let c = Credentials { user: "ada".into(), password: SecretString::from("s3cr3t-canary") };
tracing::info!(creds = ?c, "login attempt");
});
assert!(!output.contains("s3cr3t-canary"));
}
fn capture_logs(f: impl FnOnce()) -> String {
use std::sync::{Arc, Mutex};
let buf = Arc::new(Mutex::new(Vec::new()));
let writer = { let buf = buf.clone(); move || SharedWriter(buf.clone()) };
let subscriber = tracing_subscriber::fmt().with_writer(writer).finish();
tracing::subscriber::with_default(subscriber, f);
String::from_utf8(buf.lock().unwrap().clone()).unwrap()
}
struct SharedWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for SharedWriter {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> { Ok(()) }
}Use a canary value that is easy to search for. Add the same assertion for a few request flows in integration tests. In production, a log pipeline rule that flags strings shaped like keys is a last line, not a first.
The level and content guidance is in log levels and what to log, and request ids in Axum logging.


