How to Handle Secrets and Sensitive Data in Rust (secrecy crate)

Use the Rust `secrecy` crate to wrap sensitive data in a type that prevents accidental logging or printing at compile time.

The leak that shouldn't happen

You are building a login service. A user enters a password. Your code checks it against the database. The check fails. You log the error so your team can debug it. The log line looks like this: Authentication failed for user: admin, error: Invalid credentials, input: hunter2.

That log goes to your monitoring dashboard. Your entire engineering team sees it. The password is now in plain text in your logs, in your alerting system, and potentially in your long-term storage. The user's account is compromised. You didn't mean to leak it. You just passed the wrong variable to a format string.

Rust's type system is excellent at preventing memory bugs. It is less helpful when it comes to semantic bugs like leaking secrets. A String is just a String to the compiler. It doesn't know that one string is a username and another is a password. The secrecy crate fixes this by adding a type-level wrapper that makes accidental leaks impossible at compile time.

Wrapping the value

The core idea is simple. You wrap sensitive data in a Secret<T> type. This type deliberately does not implement Display or Debug. If you try to print it, log it, or format it, the compiler rejects the code. You can only access the underlying value by calling an explicit method called expose_secret.

This forces a conscious decision every time you need the raw data. You cannot accidentally pass a secret to a logging function because the function signature expects a Display type, and Secret refuses to cooperate.

Add the crate to your project. Enable the zeroize feature if you want the crate to clear the memory when the secret is dropped. This is a standard convention for handling sensitive data in Rust.

[dependencies]
secrecy = { version = "0.8", features = ["zeroize"] }

Import the types and wrap your data.

use secrecy::{Secret, ExposeSecret};

fn main() {
    // Wrap the password in a Secret type.
    // The generic parameter <String> tells the compiler what's inside.
    let password = Secret::new("hunter2".to_string());

    // This line fails to compile.
    // Secret does not implement Display or Debug.
    // println!("Password: {}", password);

    // You must explicitly expose the secret to use it.
    // This returns a reference to the inner value.
    let plain: &str = password.expose_secret();
    println!("Password: {}", plain);
}

The compiler stops you from printing password directly. You have to call expose_secret to get the string out. That method call is a signal to anyone reading the code that a secret is being revealed.

Treat the expose_secret call as a boundary. Cross it only when you absolutely need the raw value.

How the type system blocks leaks

The magic happens through trait bounds. Rust's formatting macros like println! and format! require types to implement the Display trait. The Debug trait is required for {:?} formatting. The secrecy crate defines Secret<T> in a way that never implements these traits, regardless of what T is.

Even if T is a String which implements Display, Secret<String> does not. The wrapper blocks the trait implementation. This is a form of compile-time enforcement. You cannot leak a secret by accident because the code simply won't compile.

This also affects struct derivation. If you have a configuration struct and you derive Debug, the compiler will fail if any field is a Secret.

use secrecy::Secret;

// This fails to compile.
// #[derive(Debug)]
// struct Config {
//     api_key: Secret<String>,
// }

// You must implement Debug manually and hide the secret.
struct Config {
    api_key: Secret<String>,
    endpoint: String,
}

impl std::fmt::Debug for Config {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Config")
            // Print the endpoint normally.
            .field("endpoint", &self.endpoint)
            // Hide the secret with a placeholder.
            .field("api_key", &"[REDACTED]")
            .finish()
    }
}

The compiler forces you to think about how your struct is represented in debug output. You cannot rely on #[derive(Debug)] to generate safe output for secrets. You have to write the implementation yourself and explicitly redact the sensitive fields.

Don't rely on #[derive(Debug)] for structs containing secrets. Write the implementation manually and redact the fields.

Clearing memory on drop

Printing to logs is only one way secrets leak. Another risk is memory scraping. When a variable goes out of scope, Rust drops it. For a normal String, the memory is freed and returned to the allocator. The bytes remain in memory until the allocator reuses that space for something else. A malicious process with access to your memory dump could potentially find those bytes.

The zeroize feature changes this behavior. When a Secret is dropped, the crate overwrites the underlying memory with zeros before freeing it. This reduces the window of time where the secret exists in memory.

This is not a silver bullet. Modern operating systems use swap files, and the kernel may copy memory around. A determined attacker with root access can still find secrets. However, zeroing memory protects against casual memory dumps and makes automated scraping tools less effective. It is a defense-in-depth measure.

Enable the feature in Cargo.toml and the behavior is automatic. You don't need to call any extra methods. The Drop implementation handles it.

use secrecy::Secret;

fn load_key() -> Secret<Vec<u8>> {
    // Simulate loading a key from a file or env var.
    let key_bytes = vec![1, 2, 3, 4, 5];
    Secret::new(key_bytes)
}

fn main() {
    // The key is created here.
    let key = load_key();

    // Use the key for encryption or authentication.
    let _len = key.expose_secret().len();

    // When key goes out of scope here,
    // the memory is zeroed before being freed.
}

The zeroing happens automatically when the value is dropped. You don't need to manage it manually.

Use the zeroize feature for any secret that stays in memory for more than a few milliseconds.

Realistic usage in a config struct

In real applications, secrets live inside configuration structs. You load them from environment variables or files, pass them around, and use them to initialize clients. The secrecy crate integrates well with this pattern.

A common convention is to name the variable after its purpose, not secret. Name it password, api_key, or private_key. The type Secret<String> carries the semantic meaning. The name carries the domain meaning.

use secrecy::{Secret, ExposeSecret};
use std::env;

struct AppConfig {
    database_url: String,
    // Wrap the password in Secret.
    database_password: Secret<String>,
    debug_mode: bool,
}

impl AppConfig {
    fn from_env() -> Self {
        // Load values from environment.
        // Wrap sensitive values immediately.
        Self {
            database_url: env::var("DATABASE_URL").expect("DATABASE_URL not set"),
            database_password: Secret::new(
                env::var("DATABASE_PASSWORD").expect("DATABASE_PASSWORD not set")
            ),
            debug_mode: env::var("DEBUG").is_ok(),
        }
    }
}

fn connect_to_db(config: &AppConfig) {
    // Pass the secret to the connection function.
    // The function signature requires the secret to be exposed.
    let password = config.database_password.expose_secret();
    
    // Use the password to connect.
    println!("Connecting to {} with password length {}", 
             config.database_url, 
             password.len());
}

fn main() {
    let config = AppConfig::from_env();
    connect_to_db(&config);
}

The connect_to_db function takes a reference to the config. It cannot log the password by accident. It must call expose_secret to get the string. This makes the leak point explicit in the code.

Wrap secrets as soon as you load them. Keep them wrapped until the last possible moment.

Pitfalls and limits

The secrecy crate prevents accidental leaks in your code. It does not protect against all threats.

First, secrecy does not encrypt data. It only hides it from the compiler and clears memory on drop. If you write a secret to a file without encryption, it is on disk in plain text. If you send a secret over the network without TLS, it is in transit in plain text. secrecy is a tool for memory safety, not encryption.

Second, Secret<T> implements Clone if T implements Clone. Cloning a secret creates a copy of the inner value. Both the original and the clone hold the secret in memory. Both will zero their memory when dropped. This is usually fine, but be aware that cloning increases the time the secret spends in memory.

Third, error handling can be tricky. If you panic with a secret in the message, the panic handler might print it. Rust's default panic handler prints the message. If you construct a panic message containing a secret, it leaks.

use secrecy::{Secret, ExposeSecret};

fn check_password(secret: &Secret<String>) {
    if secret.expose_secret().is_empty() {
        // This panics and prints the secret in the error message.
        // panic!("Password is empty: {}", secret.expose_secret());
        
        // Instead, use a generic error message.
        panic!("Password is empty");
    }
}

Never include secrets in panic messages or error strings. Use generic error messages that describe the problem without revealing the data.

Fourth, secrecy works with types that implement Zeroize. Most standard types like String and Vec<u8> do. If you wrap a custom type, you need to ensure it implements Zeroize or use a type that does. The crate provides a Zeroize trait from the zeroize crate.

Check your types for Zeroize support before wrapping them in Secret.

Decision matrix

Use Secret<String> when you hold text-based credentials like passwords, API keys, or tokens in memory. Use Secret<Vec<u8>> when you work with binary encryption keys or raw secret bytes. Use the zeroize feature when you need to clear sensitive data from memory as soon as the value goes out of scope. Reach for manual Debug implementations when you derive debug output for structs that contain secrets, to prevent accidental leakage in error logs. Reach for environment variables for injecting secrets into your application, but wrap them in Secret immediately after loading.

Use Secret for any data that should not appear in logs, debug output, or memory dumps. If the data is sensitive, wrap it.

Where to go next