The secret in your git history
You're deploying a microservice. It works perfectly on your laptop. You push to production, and the service crashes immediately. The logs show API_KEY not found. You check the code. There it is, hardcoded in config.rs from three weeks ago. You forgot to remove it before pushing. Now the key is in your git history, and your credit card bill is about to spike. This happens. Rust makes it easy to load secrets from the environment, but the ecosystem offers tools to keep those secrets out of your binary, out of your logs, and out of your memory longer than necessary.
Secrets belong in the environment, not the code
Think of secrets like physical keys. You don't carve the key into the front door frame. Anyone who sees the door can make a copy. You keep the key in your pocket, or in a locked box, and you hand it to the lock only when you need to open the door. In software, the "front door" is your source code and binary. The "pocket" is the runtime environment.
Rust's compiler doesn't care about secrets. It treats a secret string exactly the same as a public string. If you write let key = "sk_live_12345";, the compiler bakes that string into the binary. Anyone who can download your binary can extract the key. Your job is to ensure the secret never gets baked into the artifact the compiler produces. The standard approach is to load secrets from environment variables at runtime. The environment variables exist only in the process memory, provided by the OS or your deployment platform. They never touch the disk as part of your application code.
Loading a secret with std::env
The standard library provides std::env to access environment variables. The function env::var returns a Result<String, VarError>. This result type forces you to handle the case where the variable is missing. You can't accidentally use a secret that doesn't exist.
use std::env;
/// Attempt to load the API key from the environment.
/// Panics if the variable is missing, failing the process fast.
fn load_api_key() -> String {
// env::var returns a Result.
// expect provides a clear error message if the variable is absent.
env::var("API_KEY").expect("API_KEY must be set in the environment")
}
fn main() {
let key = load_api_key();
println!("Loaded key successfully");
}
When you run this without setting API_KEY, the program panics with the message you provided. This is the desired behavior. A service that requires a secret should fail immediately if the secret is missing, rather than running with a default value or crashing cryptically later.
If you forget to handle the Result and try to use it directly, the compiler rejects you with E0277 (trait bound not satisfied). The compiler knows you have a Result, not a String, and won't let you proceed until you unwrap or handle the error. This static check prevents silent failures where your code assumes a secret exists but gets garbage data instead.
Convention aside: Always provide a descriptive message in expect. The default message is generic. A message like API_KEY must be set tells the operator exactly what to fix. Treat the panic message as documentation for your deployment process.
Centralizing configuration
Scattering env::var calls throughout your codebase creates maintenance debt. Every call repeats the error handling logic. If you rename a variable, you have to update every call site. Real applications centralize configuration in a struct. This struct loads all secrets and settings in one place, validates them, and passes the config object to the parts of the application that need it.
use std::env;
/// Application configuration loaded from environment variables.
/// Contains secrets and operational settings.
struct Config {
database_url: String,
api_key: String,
port: u16,
debug_mode: bool,
}
impl Config {
/// Load configuration from environment variables.
/// Panics if required variables are missing or invalid.
fn from_env() -> Self {
// Load required secrets with explicit failure messages.
let database_url = env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let api_key = env::var("API_KEY")
.expect("API_KEY must be set");
// Load optional settings with defaults.
let port_str = env::var("PORT").unwrap_or_else(|_| "8080".to_string());
let port: u16 = port_str.parse().expect("PORT must be a valid number");
let debug_mode = env::var("DEBUG")
.map(|v| v == "true")
.unwrap_or(false);
Self {
database_url,
api_key,
port,
debug_mode,
}
}
}
fn main() {
// Load config once at startup.
let config = Config::from_env();
// Pass config to components.
start_server(config);
}
fn start_server(config: Config) {
println!("Starting server on port {}", config.port);
// Use config.database_url and config.api_key here.
}
This pattern gives you a single entry point for all configuration. You can add validation logic in from_env. You can check that DATABASE_URL looks like a valid connection string before the app starts. You can verify that PORT is within a valid range. If validation fails, the app panics early with a clear message. This is better than failing halfway through initialization when a database connection attempt reveals a malformed URL.
Redacting secrets in logs and debug output
Rust's println! and logging macros format values using the Display or Debug traits. If you log a config struct that contains secrets, you leak those secrets into your log files. Log files often go to centralized systems, monitoring dashboards, and support tickets. A secret in a log file is as bad as a secret in git.
If you derive Debug for your config struct, the default implementation prints every field.
#[derive(Debug)]
struct Config {
api_key: String,
port: u16,
}
Logging {:?} on this struct outputs Config { api_key: "sk_live_12345", port: 8080 }. This is a leak. You must implement Debug manually to redact sensitive fields.
struct Config {
api_key: String,
port: u16,
}
impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
.field("api_key", &"***")
.field("port", &self.port)
.finish()
}
}
Now logging the struct outputs Config { api_key: "***", port: 8080 }. The secret is hidden. This applies to any struct that holds sensitive data. Always implement Debug manually for types containing secrets. Never rely on #[derive(Debug)] for sensitive types.
Convention aside: The community considers redacting secrets in Debug output a basic hygiene requirement. If you publish a crate that handles secrets, your Debug impl must redact them. Reviewers will flag missing redaction immediately.
Redact secrets in Debug output. If your logs show ***, you're safe. If they show sk_live_..., you're leaking.
Zeroing memory with zeroize
Rust's String type allocates memory on the heap. When a String is dropped, the memory is returned to the allocator. The allocator does not zero the memory. The secret bytes remain in RAM until the allocator reuses that block for something else. In a long-running process, this means secrets can linger in memory for a long time. If an attacker can dump the process memory, they can find the secret.
For high-security applications, you need to zero the memory when the secret is no longer needed. The standard library doesn't provide a way to zero memory on drop. The zeroize crate fills this gap. It provides the ZeroizeOnDrop derive macro, which ensures the destructor writes zeros over the memory before returning it to the allocator.
use zeroize::{Zeroize, ZeroizeOnDrop};
/// A secret string that zeroes its memory on drop.
/// Use this for cryptographic keys, passwords, and tokens.
#[derive(ZeroizeOnDrop)]
struct Secret(String);
impl Secret {
fn new(value: String) -> Self {
Self(value)
}
/// Access the secret as a string slice.
/// This does not copy the data.
fn as_str(&self) -> &str {
&self.0
}
}
fn main() {
let secret = Secret::new("my-secret-key".to_string());
// Use the secret.
println!("Secret length: {}", secret.as_str().len());
// When secret goes out of scope, the memory is zeroed.
// The destructor writes zeros over the heap allocation.
}
The ZeroizeOnDrop macro generates a Drop implementation that calls zeroize on the inner value. This overwrites the memory with zeros. The secret is gone from RAM as soon as the variable goes out of scope. This mitigates memory scraping attacks.
You can also manually zero a secret if you need to clear it before the end of scope.
fn process_secret() {
let mut secret = Secret::new("temporary-key".to_string());
// Use the secret.
authenticate(secret.as_str());
// Zero the secret immediately after use.
secret.zeroize();
// The memory is now zeroed, even though secret is still in scope.
}
Convention aside: zeroize is the standard crate for memory clearing in Rust. Don't roll your own memory clearing logic. Manual pointer manipulation to zero memory is error-prone and can be optimized away by the compiler if not done carefully. zeroize uses the right intrinsics to prevent compiler optimizations from removing the zeroing.
Use zeroize for high-sensitivity data. The compiler won't zero memory for you. If the secret matters, zero it yourself.
Testing with environment variables
Testing code that depends on environment variables requires care. You can use env::set_var to set variables in tests. This modifies the process environment globally. If tests run in parallel, one test can change a variable that another test relies on, causing flaky failures.
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_config_loads_required_vars() {
// Set up test environment.
env::set_var("DATABASE_URL", "postgres://localhost/test");
env::set_var("API_KEY", "test-key");
let config = Config::from_env();
assert_eq!(config.api_key, "test-key");
}
}
This works for simple cases. For robust testing, isolate the environment. Use a library like testcontainers to spin up real services, or mock the configuration loading function. If you must use env::set_var, ensure tests that modify the environment run sequentially, or use a unique prefix for test variables to avoid collisions.
Isolate test environments. Global state like environment variables makes parallel tests flaky. Mock the config, don't mutate the world.
Development tools and production boundaries
During development, setting environment variables manually is tedious. The dotenv crate loads variables from a .env file. This is convenient for local development. You create a .env file with your secrets, and dotenv::dotenv() loads them into the environment.
fn main() {
// Load .env file if it exists.
// This is safe for development only.
let _ = dotenv::dotenv();
let config = Config::from_env();
start_server(config);
}
Never use dotenv in production. .env files often end up in Docker images or deployment artifacts by mistake. If a .env file is shipped to production, it can override secure secrets with development values, or expose secrets via the file system. dotenv belongs in development dependencies only.
[dev-dependencies]
dotenv = "0.15"
By putting dotenv in dev-dependencies, it's only available during testing and development builds. Production builds won't have access to the crate, preventing accidental usage. You can also use feature flags to gate dotenv usage.
Convention aside: Add .env to your .gitignore immediately. Never commit .env files. Treat .env files as local-only artifacts. They are development conveniences, not configuration sources for production.
Keep .env files out of production. They belong in .gitignore and your local machine. Ship environment variables, not files.
Choosing the right approach
Use std::env for simple scripts and small services where you control the deployment environment and need zero dependencies. Use the config crate when you have complex configuration with multiple sources like files, environment variables, and defaults, and you want type-safe parsing. Use zeroize when handling cryptographic keys, passwords, or tokens in long-running processes where memory scraping is a realistic threat. Use dotenv only in development to load local environment variables from a file, and never include it as a production dependency. Reach for a secrets manager like HashiCorp Vault or AWS Secrets Manager when you need rotation, auditing, and centralized control across a fleet of services.