This guide explains how to properly handle sensitive data (secrets) in the codebase to prevent accidental exposure through logs, debug output, or error messages.
Never use String for sensitive data. Always use wrapper types from src/shared/secrets/:
- API Tokens:
ApiToken(domain/infrastructure),PlainApiToken(DTO boundaries) - Passwords:
Password(domain/infrastructure),PlainPassword(DTO boundaries)
Using plain String for secrets exposes them in multiple ways:
// ❌ WRONG - Secret exposed everywhere
pub struct HetznerConfig {
pub api_token: String, // Visible in Debug, logs, error messages
}
let config = HetznerConfig {
api_token: "hetzner_abc123def456".to_string(),
};
println!("{:?}", config);
// Output: HetznerConfig { api_token: "hetzner_abc123def456" }
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// SECRET LEAKED!Secret types prevent exposure:
// ✅ CORRECT - Secret protected
use crate::shared::secrets::ApiToken;
pub struct HetznerConfig {
pub api_token: ApiToken, // Redacted in Debug output
}
let config = HetznerConfig {
api_token: ApiToken::from("hetzner_abc123def456"),
};
println!("{:?}", config);
// Output: HetznerConfig { api_token: Secret([REDACTED]) }
// ^^^^^^^^^^^^^^^^^^^
// SECRET PROTECTED!Use for API authentication tokens (REST APIs, cloud providers, etc.).
Common Fields:
HetznerConfig.api_token- Cloud provider API token- HTTP API admin tokens
- Third-party service API keys
Example:
use crate::shared::secrets::ApiToken;
use secrecy::ExposeSecret;
// Creating
let token = ApiToken::from("my-api-token-123");
// Using (only when needed for actual API calls)
let token_str: &str = token.expose_secret();Use for authentication passwords (database, SSH, services).
Common Fields:
MysqlConfig.password- Database password- SSH passwords
- Service account passwords
Example:
use crate::shared::secrets::Password;
use secrecy::ExposeSecret;
// Creating
let password = Password::from("secure_password");
// Using (only when building connection strings)
let password_str: &str = password.expose_secret();Type aliases for String used at DTO boundaries to mark temporarily transparent secrets.
When to Use: Configuration DTOs that accept user input before converting to secure types.
Example:
use crate::shared::secrets::{PlainApiToken, ApiToken};
// DTO layer (accepts plain string from user)
#[derive(Deserialize)]
pub struct HetznerProviderSection {
pub api_token: PlainApiToken, // Type alias signals "this becomes secret"
}
// Domain layer (secure type)
pub struct HetznerConfig {
pub api_token: ApiToken, // Actual secret protection
}
// Conversion
impl HetznerProviderSection {
pub fn to_domain(&self) -> HetznerConfig {
HetznerConfig {
api_token: ApiToken::from(self.api_token.clone()), // String → ApiToken
}
}
}Secrets follow this flow through application layers:
User Input (PlainApiToken/String)
↓ Deserialization
DTO Layer (PlainApiToken for clarity)
↓ Conversion
Domain Layer (ApiToken for security)
↓ Pass through
Infrastructure Layer (ApiToken.expose_secret() only when needed)
↓ Use in external calls
External API/Database
-
Use secret types for all sensitive data:
pub struct MysqlConfig { pub password: Password, // ✅ Protected }
-
Use
PlainApiToken/PlainPasswordat DTO boundaries:#[derive(Deserialize)] pub struct ConfigDto { pub api_token: PlainApiToken, // ✅ Clearly marked as temporary }
-
Call
.expose_secret()only when needed:fn connect_database(config: &MysqlConfig) -> Connection { let password = config.password.expose_secret(); // ✅ Only here Connection::new(&config.host, password) }
-
Document why secrets are exposed:
// ✅ Clear reasoning fn build_connection_string(config: &MysqlConfig) -> String { // Expose password only for connection string construction let password = config.password.expose_secret(); format!("mysql://{}:{}@{}", config.username, password, config.host) }
-
Never use
Stringfor secrets:pub struct Config { pub api_token: String, // ❌ Will leak in debug output }
-
Never log or print secrets:
let token = config.api_token.expose_secret(); println!("Token: {}", token); // ❌ NEVER DO THIS tracing::info!("Using token: {}", token); // ❌ NEVER DO THIS
-
Never include secrets in error messages:
// ❌ BAD return Err(format!("Invalid token: {}", token.expose_secret())); // ✅ GOOD return Err("Invalid token format".to_string());
-
Never store exposed secrets:
// ❌ BAD - Defeats the purpose let exposed_token = config.api_token.expose_secret().to_string(); // ✅ GOOD - Keep as secret type let token = &config.api_token;
Use .from() for test secrets:
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::secrets::ApiToken;
#[test]
fn it_should_create_config_with_api_token() {
let config = HetznerConfig {
api_token: ApiToken::from("test-token-123"),
server_type: "cx22".to_string(),
// ...
};
// Secrets are automatically redacted in test failures
assert_eq!(config.server_type, "cx22");
}
}Secret types implement PartialEq and Eq:
#[test]
fn it_should_compare_secrets() {
let token1 = ApiToken::from("same-token");
let token2 = ApiToken::from("same-token");
let token3 = ApiToken::from("different-token");
assert_eq!(token1, token2); // ✅ Works
assert_ne!(token1, token3); // ✅ Works
}Ask these questions:
- Would it be a security issue if this appeared in logs? → Use secret type
- Is this used for authentication/authorization? → Use secret type
- Should this be redacted in debug output? → Use secret type
- Does this access protected resources? → Use secret type
Common Secret Patterns:
- Anything named
*_token,*_password,*_secret,*_key - Database credentials
- API keys and tokens
- Private keys
- Authentication credentials
When adding a new secret field:
- Choose correct type (
ApiTokenfor tokens,Passwordfor passwords) - Use
Plain*type alias at DTO boundaries if accepting user input - Update struct field to use secret type
- Add
use crate::shared::secrets::{ApiToken, PlainApiToken};import - Update construction sites to use
.from() - Update usage sites to call
.expose_secret()only when necessary - Verify debug output redacts the secret
- Update all tests to use secret types
- Check serialization/deserialization works correctly
- Document why
.expose_secret()is called at each location
- Architecture Decision:
docs/decisions/secrecy-crate-for-sensitive-data.md- Why we use the secrecy crate - Implementation Details:
src/shared/secrets/- Source code for secret types - Refactor Plan:
docs/refactors/plans/secret-type-introduction.md- Migration tracking
Secret types provide multiple layers of protection:
- Debug Redaction:
Debugoutput showsSecret([REDACTED])instead of actual value - Memory Zeroing: Secrets are wiped from memory when dropped (via
zeroizecrate) - Explicit Access:
.expose_secret()makes secret usage visible in code - Type Safety: Compiler enforces correct handling
- Serialization Control: Can serialize when needed (config files) but remains protected in memory
Human error is inevitable. Type-level protection prevents mistakes at compile time.
Yes, secret types implement Serialize/Deserialize for configuration files. However, they remain protected in memory and debug output.
Call .expose_secret() - but document why it's necessary. This makes secret usage explicit and auditable.
No, only sensitive data. Regular strings for non-secret configuration values are fine.
Use them at DTO boundaries to clearly mark "this will become a secret." They're just type aliases for String but signal intent.