
Preventing Rust Logging Leaks: Designing Security-Safe Telemetry Without Exposing Secrets
Why logging leaks happen
Logging leaks usually appear in one of four ways:
- Direct interpolation of sensitive values
Example: info!("token={token}")
- Derived debug output
Example: debug!("{:?}", request) where request contains credentials
- Error propagation with context
Example: wrapping errors that include raw request bodies or headers
- Serialization of whole structs
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 type | Example | Log policy |
|---|---|---|
| Public metadata | request_id, status_code | Safe to log |
| Authentication material | Authorization, session cookie | Never log raw value |
| Secrets | API key, private key, refresh token | Redact or omit |
| User content | message body, form fields | Log 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:
AuthorizationCookie- 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 mechanism | Risk | Recommendation |
|---|---|---|
Debug | Often used in logs and panics | Redact sensitive fields |
Display | May be used in user-facing messages and logs | Keep concise and non-sensitive |
Serialize | Can leak secrets into JSON logs or telemetry | Serialize only approved fields |
Clone | Copies secrets into more places | Use 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:?}")whenerrmay contain raw inputanyhow::Contextmessages 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
Debugfor 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.
| Approach | Security | Debuggability |
|---|---|---|
| Log everything | Poor | High |
| Redact secrets only | Good | Good |
| Log explicit approved fields | Very good | Good |
| Disable logs entirely | Very good | Low |
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.
