Why logging leaks happen

Logging leaks usually appear in one of four ways:

  1. Direct interpolation of sensitive values
  2. Example: info!("token={token}")

  1. Derived debug output
  2. Example: debug!("{:?}", request) where request contains credentials

  1. Error propagation with context
  2. Example: wrapping errors that include raw request bodies or headers

  1. Serialization of whole structs
  2. Example: logging a JSON payload that contains passwords or access tokens

The problem is not logging itself. The problem is logging too much or logging the wrong representation of a value. In security-sensitive code, your goal is to make the safe path the default.


Start with a data classification mindset

Before writing code, decide which fields are safe to log and which are not. A useful rule is:

  • Safe to log: request IDs, status codes, feature flags, public resource identifiers
  • Sensitive: passwords, bearer tokens, API keys, session cookies, private keys, personal data, raw request bodies, authorization headers

A simple classification table helps teams stay consistent:

Data typeExampleLog policy
Public metadatarequest_id, status_codeSafe to log
Authentication materialAuthorization, session cookieNever log raw value
SecretsAPI key, private key, refresh tokenRedact or omit
User contentmessage body, form fieldsLog only when explicitly approved and sanitized

Once you classify data, encode the policy in your types and logging helpers.


Prefer redaction-aware wrapper types

A strong pattern in Rust is to wrap sensitive values in a type that intentionally hides its contents in Debug output.

use std::fmt;

pub struct Secret<T>(T);

impl<T> Secret<T> {
    pub fn new(value: T) -> Self {
        Self(value)
    }

    pub fn expose(&self) -> &T {
        &self.0
    }
}

impl<T> fmt::Debug for Secret<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("[REDACTED]")
    }
}

Use it like this:

#[derive(Debug)]
struct LoginRequest {
    username: String,
    password: Secret<String>,
}

fn main() {
    let req = LoginRequest {
        username: "alice".to_string(),
        password: Secret::new("correct horse battery staple".to_string()),
    };

    println!("{req:?}");
}

Output:

LoginRequest { username: "alice", password: [REDACTED] }

This pattern is simple, but it is effective because it changes the default behavior. Developers can still access the underlying value when needed, but accidental debug logging becomes much safer.

When to use wrapper types

Use wrappers for:

  • passwords
  • bearer tokens
  • API keys
  • private keys
  • refresh tokens
  • session secrets

Avoid using wrappers for values that are not sensitive, because over-redaction can make logs less useful and encourage unsafe workarounds.


Avoid logging whole request or response objects

A common mistake is logging an entire HTTP request, database row, or application struct because it is convenient. In security-sensitive code, convenience is often the enemy.

Instead of this:

tracing::debug!(?request, "incoming request");

Prefer explicit fields:

tracing::debug!(
    method = %request.method(),
    path = %request.uri().path(),
    request_id = %request_id,
    "incoming request"
);

This approach makes the log event intentional. You choose which fields are emitted, and you avoid accidental inclusion of headers, cookies, or body content.

Good practice for request logging

Log:

  • method
  • path
  • status code
  • request ID
  • latency
  • user or tenant ID if it is not sensitive in your context

Do not log:

  • Authorization
  • Cookie
  • raw body
  • full query strings if they may contain secrets
  • internal headers that carry credentials

If you need to inspect payloads during development, use a temporary local-only diagnostic path, not production logging.


Use structured logging with field-level control

Rust’s structured logging ecosystem, especially tracing, makes it easier to log fields explicitly instead of concatenating strings. That improves searchability and reduces accidental leakage.

use tracing::{info, warn};

fn authenticate(user_id: &str, success: bool) {
    if success {
        info!(user_id = %user_id, "authentication succeeded");
    } else {
        warn!(user_id = %user_id, "authentication failed");
    }
}

Notice that the log includes the user ID but not the password, token, or raw authentication payload.

Why structured logs help

Structured logs make it easier to:

  • redact at the field level
  • filter by key
  • enforce schema-based policies
  • send only approved fields to external observability systems

They also reduce the temptation to build large formatted strings that accidentally include secrets.


Be careful with Display, Debug, and serde

A type can be safe in one context and unsafe in another.

Trait or mechanismRiskRecommendation
DebugOften used in logs and panicsRedact sensitive fields
DisplayMay be used in user-facing messages and logsKeep concise and non-sensitive
SerializeCan leak secrets into JSON logs or telemetrySerialize only approved fields
CloneCopies secrets into more placesUse intentionally, especially for long-lived secrets

If you derive Debug on a struct that contains secrets, you are usually making a mistake:

#[derive(Debug)]
struct ApiCredentials {
    client_id: String,
    client_secret: String,
}

A safer version is:

use std::fmt;

struct ApiCredentials {
    client_id: String,
    client_secret: Secret<String>,
}

impl fmt::Debug for ApiCredentials {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("ApiCredentials")
            .field("client_id", &self.client_id)
            .field("client_secret", &self.client_secret)
            .finish()
    }
}

Because Secret<String> already redacts itself, the struct-level Debug implementation remains safe.


Sanitize errors before they reach logs

Errors are another common leak source. A low-level error may include a URL with credentials, a malformed header value, or a raw payload snippet. If you bubble that error directly into logs, you may expose the exact data you were trying to protect.

A safer pattern is to separate:

  • internal diagnostic detail
  • external log message
  • user-facing error

For example:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum AuthError {
    #[error("authentication failed")]
    Failed,
    #[error("configuration error")]
    Config,
}

Then log the error with context that does not include secrets:

fn handle_auth_error(user_id: &str, err: &AuthError) {
    tracing::warn!(user_id = %user_id, error = %err, "authentication error");
}

If you need deeper diagnostics, attach them only to secure internal telemetry channels that are access-controlled and retention-limited.

Avoid these patterns

  • error!("login failed: {err:?}") when err may contain raw input
  • anyhow::Context messages that embed secrets
  • logging full parsing failures for secret-bearing payloads

A good rule: error messages should explain what failed, not dump what the user sent.


Redact at the boundary, not everywhere

A maintainable logging design centralizes redaction at the edges of your system:

  • HTTP middleware
  • request extractors
  • domain model constructors
  • telemetry adapters

This is better than scattering if sensitive { ... } checks across the codebase.

For example, you can define a request context type that only stores safe fields:

struct RequestContext {
    request_id: String,
    user_id: Option<String>,
    route: String,
}

Then build it from the incoming request without copying headers or bodies into the context object. Your logging layer only sees RequestContext, which is already safe by construction.

This pattern scales well because developers cannot accidentally log fields that were never stored.


Use log filtering and retention controls

Even well-designed logs should be treated as sensitive data. Security-safe logging is not only about code; it is also about operational controls.

Recommended practices:

  • restrict access to production logs
  • separate debug logs from production logs
  • use short retention for verbose logs
  • encrypt logs at rest and in transit
  • avoid shipping raw logs to third-party tools without review
  • apply field-level scrubbing in your log pipeline when possible

If your infrastructure supports it, configure a redaction layer in the logging backend as a defense in depth measure. Application-level redaction is still necessary because once a secret is emitted, it may be copied into multiple systems.


Test for accidental leakage

Security logging should be tested like any other security boundary. Add tests that assert secrets do not appear in formatted output.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn secret_is_redacted_in_debug_output() {
        let secret = Secret::new("top-secret".to_string());
        let output = format!("{secret:?}");
        assert_eq!(output, "[REDACTED]");
        assert!(!output.contains("top-secret"));
    }
}

You can also test higher-level types:

#[test]
fn login_request_debug_does_not_expose_password() {
    let req = LoginRequest {
        username: "alice".to_string(),
        password: Secret::new("s3cr3t".to_string()),
    };

    let output = format!("{req:?}");
    assert!(!output.contains("s3cr3t"));
}

These tests are cheap and catch regressions when someone later adds a derived Debug or changes a log statement.


A practical checklist for safer Rust logging

Use this checklist during code review:

  • Log explicit fields, not whole objects
  • Wrap secrets in redaction-aware types
  • Implement custom Debug for sensitive structs
  • Avoid logging raw headers, bodies, and query strings
  • Sanitize errors before logging them
  • Keep production logs access-controlled and short-lived
  • Test that sensitive values do not appear in formatted output
  • Review serialization paths used by telemetry and diagnostics

If a log line would be dangerous in a support ticket or screenshot, it is probably too sensitive for production telemetry.


Common trade-offs

Security-safe logging sometimes reduces debugging convenience. That trade-off is usually worth it, but you can manage it carefully.

ApproachSecurityDebuggability
Log everythingPoorHigh
Redact secrets onlyGoodGood
Log explicit approved fieldsVery goodGood
Disable logs entirelyVery goodLow

The best balance for most systems is explicit structured logging with redaction-aware types and strong operational controls.


Conclusion

Rust gives you the tools to build safer logging systems, but it does not enforce good telemetry hygiene by default. The key is to treat logs as a security boundary: classify sensitive data, redact at the type level, log explicit fields, and test for accidental exposure.

When you design your logging model up front, you reduce the chance that secrets will leak into observability systems, support tools, or incident reports. That makes your application easier to operate without turning logs into a liability.

Learn more with useful resources