
Preventing Secret Leakage in Rust: Designing APIs That Keep Credentials Out of Logs, Panics, and Debug Output
Why secret leakage happens
Secret leakage is usually not a cryptographic failure. It is a software design problem: a value meant for internal use gets copied into a place that is easy to observe.
Common leak paths include:
println!("{:?}", value)in development or incident response- structured logs that serialize request bodies or config structs
- panic messages that include user input or credentials
- error types that store and expose raw secrets
Clone,Serialize, orDisplayimplementations that reveal internal fields
In Rust, this often happens because a type is convenient to inspect, not because the developer intended to expose data. The safest approach is to make the secure behavior the default.
Use wrapper types for sensitive values
A strong pattern is to wrap secrets in a dedicated type that controls how the value is displayed, cloned, and serialized. This keeps the security policy close to the data.
A minimal wrapper can hide its contents in Debug and Display 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]")
}
}
impl<T> fmt::Display for Secret<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("[REDACTED]")
}
}This design makes accidental logging much less dangerous:
let api_key = Secret::new(String::from("sk_live_123456"));
println!("{:?}", api_key); // [REDACTED]Why a wrapper is better than a convention
A comment like “do not log this field” is easy to miss. A wrapper type enforces the policy at compile time. If the secret is embedded in a larger struct, the redaction behavior propagates through that struct’s derived Debug output as long as the field type implements redaction correctly.
Avoid deriving Debug on secret-bearing structs
#[derive(Debug)] is convenient, but it is also a common source of leakage. If a struct contains credentials, tokens, or private keys, derive Debug only if every sensitive field is already protected by a redacting wrapper.
Consider this unsafe pattern:
#[derive(Debug)]
struct DbConfig {
username: String,
password: String,
}A debug print of DbConfig exposes the password immediately.
A safer version wraps the secret:
#[derive(Debug)]
struct DbConfig {
username: String,
password: Secret<String>,
}Now Debug output remains useful without revealing the credential.
Prefer explicit formatting for operational logs
If a type is used in logs, define a custom Debug implementation that includes only non-sensitive fields. This is especially useful for request and configuration objects.
use std::fmt;
struct AuthRequest {
user_id: String,
token: Secret<String>,
}
impl fmt::Debug for AuthRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AuthRequest")
.field("user_id", &self.user_id)
.field("token", &self.token)
.finish()
}
}This keeps logs informative while preventing accidental disclosure.
Redact secrets in errors, not just logs
Error handling is another frequent leak source. A developer may think “this error is internal,” but errors often bubble up into telemetry, HTTP responses, or panic reports.
Avoid storing raw secrets in error variants:
enum AuthError {
InvalidToken(String),
}Instead, store context that is useful for diagnosis but not the secret itself:
enum AuthError {
InvalidToken,
MissingCredentials,
BackendUnavailable,
}If you need to include a secret-like value for debugging, store a redacted wrapper or a hash prefix rather than the original value.
Be careful with thiserror and anyhow
Convenience crates make error propagation easy, but they can also preserve too much context. When using #[error("...")], ensure the formatted message does not interpolate secrets.
Bad:
#[derive(thiserror::Error, Debug)]
enum LoginError {
#[error("invalid password for user {user}: {password}")]
InvalidPassword { user: String, password: String },
}Better:
#[derive(thiserror::Error, Debug)]
enum LoginError {
#[error("invalid password")]
InvalidPassword,
}If you need to correlate incidents, use a request ID, account ID, or opaque event ID instead of the secret itself.
Control serialization behavior
Secrets often leak through JSON, YAML, or other structured output. If a type is serialized for APIs, metrics, or audit logs, make sure secret fields are excluded or redacted.
A common approach is to use serde attributes:
use serde::Serialize;
#[derive(Serialize)]
struct PublicProfile {
username: String,
#[serde(skip_serializing)]
api_token: String,
}However, skip_serializing removes the field entirely. For logs or admin tools, redaction is often better than omission. You can implement custom serialization for a wrapper type so that the field appears as a placeholder.
Redaction strategy comparison
| Strategy | Output | Best for | Risk |
|---|---|---|---|
| Omit field | Field absent | Public API responses | Harder to diagnose missing data |
| Redact field | "[REDACTED]" | Logs, admin views | Placeholder may be mistaken for real data if not documented |
| Hash or fingerprint | Stable opaque value | Correlation across systems | Can still aid brute-force analysis if overused |
| Expose raw value | Original secret | Never recommended | Direct leakage |
For most application logs, redaction is the safest default.
Minimize cloning and ownership spread
Rust ownership helps reduce accidental sharing, but secrets can still be cloned into many places. Each clone increases the number of memory locations that must be protected and zeroized later.
Prefer borrowing when possible:
fn authenticate(password: &str) -> bool {
password == "correct horse battery staple"
}If a secret must be stored, keep it in one place and pass references to consumers. Avoid converting secrets into String repeatedly, especially in helper functions that format messages or build JSON payloads.
Keep secret lifetimes short
The longer a secret lives, the more opportunities there are for accidental logging, panic, or memory inspection. Scope secret values tightly:
fn handle_login(input: &str) -> Result<(), AuthError> {
let password = Secret::new(input.to_owned());
if password.expose().is_empty() {
return Err(AuthError::MissingCredentials);
}
// Use password only here.
Ok(())
}The goal is not to make secrets impossible to inspect, but to reduce the number of places where inspection can happen.
Zeroize secrets when they are no longer needed
Redaction prevents disclosure through output channels. Zeroization reduces the chance that secrets remain in memory after use.
For sensitive byte buffers, use a type that clears memory on drop. The zeroize crate is commonly used for this purpose.
use zeroize::Zeroize;
struct SessionKey {
bytes: Vec<u8>,
}
impl Drop for SessionKey {
fn drop(&mut self) {
self.bytes.zeroize();
}
}This is especially relevant for:
- private keys
- decrypted payloads
- password buffers
- session tokens held in memory for extended periods
Zeroization is not a complete defense against all memory disclosure scenarios, but it is a valuable layer in depth.
Design APIs that make unsafe behavior hard
If you are building a library, your API shape determines whether callers can accidentally leak secrets. Favor interfaces that make safe usage obvious and unsafe usage awkward.
Good API traits
- secret-bearing types do not implement raw
Display - debug output is redacted by default
- serialization is explicit, not automatic
- secret extraction requires an intentional method like
expose() - error types avoid embedding raw sensitive values
A useful pattern is to separate public data from secret data:
struct LoginForm {
username: String,
password: Secret<String>,
}
struct LoginAudit {
username: String,
outcome: &'static str,
}The audit record can be logged safely, while the form remains protected.
Avoid “transparent” newtypes for secrets
#[repr(transparent)] and Deref<Target = T> can make wrappers feel convenient, but they also make it easier to treat secrets like ordinary strings. That convenience often undermines the security boundary. Prefer explicit accessors over automatic dereferencing.
Handle panic paths carefully
Panic messages can surface in logs, crash reports, and monitoring systems. Even if your application uses catch_unwind in some places, a panic is still a risky place to include sensitive data.
Avoid code like this:
panic!("authentication failed for token {}", token);Instead, panic with a generic message or, better, return an error:
return Err(AuthError::InvalidToken);If a panic is truly unavoidable, keep the message free of secrets and user input. The panic should describe the invariant violation, not the data involved.
Practical checklist for secret-safe Rust code
Use this checklist when reviewing code that handles credentials or other sensitive values:
- Wrap secrets in a dedicated redacting type
- Do not derive
Debugon secret-bearing structs unless fields are protected - Keep error messages generic and non-sensitive
- Redact or omit secret fields during serialization
- Prefer borrowing over cloning
- Keep secret lifetimes short
- Zeroize buffers when appropriate
- Avoid panic messages that include secrets
- Review logs, traces, and audit events as part of the threat model
A realistic example: safer database credentials
Suppose your application loads database credentials from configuration and logs startup state.
Unsafe version:
#[derive(Debug)]
struct AppConfig {
db_url: String,
db_password: String,
}Safer version:
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]")
}
}
#[derive(Debug)]
struct AppConfig {
db_url: String,
db_password: Secret<String>,
}
fn connect(config: &AppConfig) {
println!("starting with config: {:?}", config);
// db_password stays redacted
}This version still supports debugging and observability, but it avoids exposing the credential in routine output.
Conclusion
Secret leakage in Rust is usually preventable with disciplined API design. The key idea is to make sensitive values hard to print, hard to serialize, and hard to propagate accidentally. Redacting wrappers, explicit error messages, careful serialization, and short secret lifetimes provide a strong baseline for secure applications and libraries.
Rust gives you the tools to express these constraints clearly. Use them to make the secure path the default path.
