
Preventing Sensitive Data Exposure in Rust: Designing Safer Error Messages and Debug Output
Why sensitive data leaks happen
Sensitive data exposure is rarely caused by a single obvious mistake. More often, it comes from convenience features used in the wrong context:
#[derive(Debug)]prints every field by default- error messages include raw input or secrets
unwrap()andexpect()reveal internal state in panic messages- tracing and logging capture request payloads, headers, or credentials
- third-party error wrappers preserve too much context
In production, diagnostics should help operators troubleshoot without turning logs into a secret archive. The goal is not to eliminate all detail, but to control where detail appears and who can see it.
Common leak sources in Rust applications
| Source | Typical risk | Safer approach |
|---|---|---|
Debug on structs | Prints secret fields automatically | Implement custom Debug or redact fields |
Display on errors | Exposes raw input or credentials | Return stable, user-safe messages |
unwrap() / expect() | Panic messages may reveal state | Handle errors explicitly |
| Logging request bodies | Captures passwords, tokens, PII | Log metadata, not payloads |
| Error chaining | Includes low-level details in public responses | Separate internal logs from external errors |
A secure design treats every diagnostic channel as potentially persistent and broadly accessible.
Redact secrets at the type boundary
The safest place to prevent leaks is at the data type itself. If a field should never be printed, make that behavior explicit.
Avoid deriving Debug blindly
#[derive(Debug)]
struct ApiCredentials {
client_id: String,
client_secret: String,
}This is convenient, but dangerous. Any debug print of ApiCredentials will include the secret.
Instead, implement Debug manually and redact sensitive fields:
use std::fmt;
struct ApiCredentials {
client_id: String,
client_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", &"[REDACTED]")
.finish()
}
}This pattern is simple and effective. It preserves enough context for debugging while preventing accidental disclosure.
Use wrapper types for sensitive values
A dedicated wrapper makes intent clearer and reduces the chance of accidental printing.
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]")
}
}You can then use it in application state:
struct Config {
database_url: Secret<String>,
jwt_signing_key: Secret<Vec<u8>>,
}This does not replace secure storage or memory hygiene, but it prevents the most common accidental leak: printing the value.
Design errors for users, not for operators
A frequent mistake is returning the same error message to both end users and internal logs. That often leads to one of two bad outcomes:
- user-facing messages are too detailed and leak internals
- internal logs are too generic and become useless
The solution is to separate public error messages from internal diagnostics.
Use structured error types
A custom error enum can carry internal context while exposing a safe display string.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AuthError {
#[error("authentication failed")]
InvalidCredentials,
#[error("authentication service unavailable")]
BackendUnavailable,
}This keeps the public message stable and non-sensitive. If you need more detail for logs, attach it separately in the logging layer.
Preserve internal context without exposing it directly
#[derive(Debug, Error)]
pub enum PaymentError {
#[error("payment processing failed")]
ProcessingFailed {
#[source]
source: std::io::Error,
},
}The Display text is safe for users, while the source chain remains available to internal tooling. Be careful, though: if you serialize or print the full error chain in a public response, you may reintroduce the leak.
Control what gets logged
Logging is one of the most common leak vectors in Rust services. The issue is not logging itself, but logging the wrong data.
Prefer metadata over payloads
Bad example:
tracing::info!("login request: {:?}", request);If request contains a password, token, or session cookie, that data goes straight into logs.
Better:
tracing::info!(
user_id = %request.user_id,
method = %request.method,
"login request received"
);This logs enough to correlate events without dumping the entire request object.
Be careful with tracing fields
Structured logging is safer than string interpolation, but only if the fields themselves are safe. Avoid attaching raw secrets as span fields or event fields.
If you must inspect a sensitive value during debugging, do it in a controlled environment and ensure the output cannot reach production logs.
Redact at the logging boundary
If your application accepts rich domain objects, consider creating log-specific views:
struct LoginAttempt<'a> {
username: &'a str,
ip: &'a str,
}
impl<'a> From<&'a LoginRequest> for LoginAttempt<'a> {
fn from(req: &'a LoginRequest) -> Self {
Self {
username: &req.username,
ip: &req.ip,
}
}
}Then log only LoginAttempt, not the full request.
Avoid leaking secrets in panics
Panic messages can end up in crash reports, container logs, or monitoring systems. A panic that includes raw input may become a permanent leak.
Do not use expect() with sensitive context
let token = env::var("API_TOKEN").expect("missing API_TOKEN");This is usually acceptable because the variable name is not secret. But avoid messages like:
let key = parse_key(&input).expect("invalid key: user supplied value was ...");If the input contains a secret, the panic message may expose it.
Prefer explicit error handling
fn load_key(input: &str) -> Result<Vec<u8>, KeyError> {
parse_key(input).map_err(|_| KeyError::InvalidFormat)
}This lets you decide what gets returned, logged, or surfaced to the user.
Reserve panics for unrecoverable programmer errors
Panics should indicate invariant violations, not validation failures. If the input comes from outside the process, treat it as data, not as a reason to panic.
Keep secrets out of Display too
Debug is the obvious risk, but Display can leak just as easily. Many libraries use Display in logs, error pages, and CLI output.
If a type contains sensitive data, decide whether its Display representation should ever include that data. In most cases, it should not.
Example: safe display for a token-bearing type
use std::fmt;
struct Session {
id: String,
token: String,
}
impl fmt::Display for Session {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Session(id={})", self.id)
}
}This avoids accidental exposure when the type is printed in user messages or logs.
Handle third-party errors carefully
External crates may include detailed context in their error types. That detail can be useful internally, but dangerous if forwarded directly to users.
Wrap external errors at the boundary
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("database operation failed")]
Database,
}Inside your service, log the original error with appropriate access controls. Outside the service, return the sanitized AppError.
A useful rule is:
- internal logs: detailed, access-controlled
- public API responses: minimal, stable, non-sensitive
This separation is especially important in web services, CLI tools used in automation, and multi-tenant systems.
Test for accidental exposure
Security controls are only useful if they stay in place. Add tests that verify sensitive values do not appear in formatted output.
Example test for redaction
#[test]
fn debug_redacts_secret() {
let creds = ApiCredentials {
client_id: "client-123".to_string(),
client_secret: "super-secret".to_string(),
};
let output = format!("{:?}", creds);
assert!(!output.contains("super-secret"));
assert!(output.contains("[REDACTED]"));
}This kind of test is cheap and catches regressions when someone later adds derive(Debug) or changes formatting logic.
You can also test error messages:
#[test]
fn auth_error_is_safe_to_display() {
let err = AuthError::InvalidCredentials;
assert_eq!(err.to_string(), "authentication failed");
}Practical checklist for safer diagnostics
Use this checklist when reviewing Rust code that handles secrets or user data:
- Do not derive
Debugfor secret-bearing types without reviewing the output - Redact sensitive fields in
DebugandDisplay - Separate user-facing errors from internal logs
- Avoid logging request bodies, headers, tokens, or passwords
- Treat panic messages as potentially public
- Wrap third-party errors before exposing them outside the service
- Add tests that assert secrets do not appear in formatted output
These habits are especially important in authentication systems, payment flows, admin tooling, and any service that processes credentials.
A secure pattern in practice
The following example combines several of the ideas above: redacted debug output, safe error messages, and controlled logging.
use std::fmt;
use thiserror::Error;
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]")
}
}
#[derive(Debug)]
pub struct LoginRequest {
pub username: String,
pub password: Secret<String>,
}
#[derive(Debug, Error)]
pub enum LoginError {
#[error("invalid username or password")]
InvalidCredentials,
}
pub fn authenticate(req: &LoginRequest) -> Result<(), LoginError> {
if req.username == "admin" && req.password.expose() == "correct-horse-battery-staple" {
Ok(())
} else {
Err(LoginError::InvalidCredentials)
}
}This code still needs real password verification and secure storage, but it demonstrates the core security principle: sensitive values should not be printable by default.
