Skip to content
rusthacks.dev
Security

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.

4 min read
Summarize in AI
Flat illustration for the post “Logging Without Leaking Secrets in Rust Web Services”, composed around security.

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

rust
#[derive(Debug)]
struct Credentials {
    user: String,
    password: String,
}

fn main() {
    let c = Credentials { user: "ada".into(), password: "hunter2".into() };
    println!("login attempt: {c:?}");
}
Text
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

rust
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}");
}
Text
login attempt: Credentials { user: "ada", password: SecretBox<str>([REDACTED]) }
true

SecretString'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

rust
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:

rust
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, Cookie or Set-Cookie. If you use tower_http::trace::TraceLayer, its default span does not include headers, and a custom span should list the safe ones only. tower_http::sensitive_headers marks headers as sensitive so they print as Sensitive in Debug.
  • **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.
rust
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

rust
#[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.

Also inTooling
Share

Keep reading

Flat illustration for the post “Request Limits and Slow Client Attacks in Rust Services”, composed around web.
Security

Request Limits and Slow Client Attacks in Rust Services

Slow clients and oversized requests exhaust connections and memory. Set header and body timeouts, body size limits, concurrency limits and keep-alive bounds at the proxy and in the Axum service with Tower layers.

5 min read

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.