secrets-rs
A Rust library for safely retrieving and using secrets in applications, primarily for configuration.
A secret's real value must be explicitly requested. Every default access path — Display, Debug, and serde serialization — emits a masked value that is safe to include in logs and error reports.
Concepts
Secret
Secret<T> is a generic wrapper around a typed value. The supported types are:
| Type | T |
|---|---|
| UTF-8 string | String |
| Raw bytes | Vec<u8> |
| JSON | serde_json::Value |
A secret is identified by a URN of the form:
urn:secrets-rs:<source_id>:<name>
The scheme (urn) and NID (secrets-rs) are case-insensitive per RFC 8141. The case sensitivity of source_id and name depends on the source.
Masked value
Until a secret is bound, or whenever it is displayed by default, it shows a masked value:
urn:secrets-rs:env:MY_API_KEY [UNBOUND] # before binding
urn:secrets-rs:env:MY_API_KEY [string:22] # after binding
The format is <urn> [<type>:<size>]. Calling .value() before binding returns an error.
Sources
A source is anything that can look up a secret by name and return its raw bytes. Sources are registered in a SourceRegistry keyed by the source_id from the URN.
Built-in sources:
| Source | source_id convention |
Backed by | Primary use case |
|---|---|---|---|
EnvSource |
e.g. "env" |
std::env::var |
API keys, passwords |
FileSource |
e.g. "file" |
std::fs::read |
TLS keys and certificates |
FileSource uses the secret name directly as a file path. Two construction modes are available:
FileSource::new()— resolves relative paths against the process's current working directory at call time. Simple, but non-deterministic if any code callsset_current_dirconcurrently.FileSource::with_base(dir)— captures an absolute base directory at construction time and resolves all relative paths against it, regardless of later CWD changes. Absolute paths in the URN name are still used as-is.
urn:secrets-rs:file:/etc/ssl/private/server.key // absolute — same in both modes
urn:secrets-rs:file:certs/ca.crt // relative — stable only with with_base
// Stable resolution — recommended for multi-threaded programs
registry.register.unwrap;
Security: Because the URN name is used as a filesystem path without validation, binding a
FileSourcesecret with an attacker-controlled URN is an arbitrary file-read vulnerability. Only bind URNs that come from trusted configuration (static code, operator-supplied config files with restricted write permissions, etc.). Never accepturn:secrets-rs:file:...URNs from untrusted input such as API requests, user-supplied data, or deserialized network payloads.with_baseanchors relative resolution to a known directory but does not prevent path-traversal sequences (../) from escaping it; the trusted-configuration requirement still applies.
SourceRegistry
SourceRegistry maps the source_id component of a URN to the Source implementation that resolves it. When bind is called on a secret, the registry looks up the source by the source_id extracted from the secret's URN and delegates to its get method.
// EnvSource is already registered. Add FileSource for filesystem secrets.
let mut registry = new;
registry.register.unwrap;
// urn:secrets-rs:env:... → resolved by EnvSource (default)
// urn:secrets-rs:file:... → resolved by FileSource
A few design points worth knowing:
EnvSource is pre-registered under "env". SourceRegistry::new() registers EnvSource automatically, so env-backed secrets work without any manual setup. Calling register("env", ...) again replaces it, which is useful in tests or when you need a custom env implementation.
The source_id is application-defined for everything else, with a recommended naming convention. Prefix the id with the source type, separated by -:
| Pattern | Example | When to use |
|---|---|---|
"file" |
"file" |
Single FileSource instance |
"file-<qualifier>" |
"file-certs", "file-keys" |
Multiple FileSource instances with different base directories |
"env-<qualifier>" |
"env-prod", "env-staging" |
Multiple env sources pointing to different environments |
This makes the backend immediately readable from the URN itself — urn:secrets-rs:file-certs:server.der is unambiguously file-backed, without inspecting the registry. Any string that passes character validation is accepted; the convention is not enforced by the library.
Sources are type-erased. SourceRegistry stores Box<dyn Source>, so you can mix arbitrary Source implementations in the same registry without the registry itself being generic. The Source trait requires Send + Sync, which means a registry in an Arc is safe to share across threads.
Secrets and the registry are decoupled. A Secret is just a URN until bind is called — it holds no reference to any source. This means secrets can be constructed or deserialized freely before sources are configured, and you can supply a different registry in tests without changing how secrets are declared.
Binding
Binding resolves a secret from its source and stores the typed value inside the Secret<T> struct. You can bind secrets individually with Secret::bind, or bind every secret in a struct at once with bind_all.
Usage
Add the dependency
[]
= "1.0"
Individual binding
use ;
let mut api_key: =
new?;
// EnvSource is registered under "env" by default.
let registry = new;
api_key.bind?;
// Safe to log — shows the masked value
println!;
// Explicit opt-in to the real value
let key: &str = api_key.value?;
Config struct with #[derive(Bindable)]
For structs that contain multiple secrets, derive Bindable to generate bind_all support automatically. Non-Secret fields are ignored. The derive macro is provided by the secrets-rs-macros crate, re-exported as secrets_rs::Bindable.
use ;
let mut config = AppConfig ;
// EnvSource is registered under "env" by default.
// Binds db_password and api_key; collects all errors rather than
// stopping at the first failure.
let registry = new;
bind_all?;
Without the derive macro, implement Bindable manually:
use ;
Serde integration
Secret<T> implements both Serialize and Deserialize:
- Serialize — always produces the masked value string, safe to use in any context.
- Deserialize — accepts a
urn:secrets-rs:<source_id>:<name>string and produces an unbound secret. Non-URN strings are rejected with an error.
This means a config file can hold URN strings and be deserialized directly into a typed struct; bind_all is then called to resolve the actual values from their sources.
// Deserialize URNs from config file, then bind to real values
let mut config: AppConfig = from_str?;
bind_all?;
// Serializes as "urn:secrets-rs:env:DB_PASSWORD [string:28]"
println!;
Sharing secrets across subsystems
Secret<T> deliberately does not implement Clone. Cloning a bound secret would create a second full copy of the secret value in memory, multiplying the number of locations an attacker could read it from in a core dump, swap, or cold-boot scenario. The library's position is that there should be one copy of each secret value in memory at a time, shared by reference.
When multiple parts of an application need access to the same secret, prefer keeping one bound copy and distributing a reference to it rather than creating multiple independent copies. Three patterns apply, in order of preference:
1. Arc<AppConfig> — bind once, share the whole config (recommended)
The simplest approach: build and bind your config struct at startup, wrap it in an Arc, and hand clones of the Arc to each subsystem. No secret values are duplicated; all subsystems read from the same allocation.
use Arc;
use ;
let mut config = AppConfig ;
bind_all?;
let config = new; // bind_all consumed &mut, now freeze
let config_for_worker = clone; // zero-copy share
2. Arc<Secret<T>> — share a single bound secret
When only one secret needs to be shared (rather than an entire config struct), wrap just that secret in an Arc after binding.
use Arc;
use ;
let mut api_key: = new?;
api_key.bind?;
let api_key = new;
let key_for_worker = clone;
3. Secret::urn() — construct an independent unbound secret
When two subsystems must bind independently — for example because they use different registries or have different initialization lifetimes — use [Secret::urn] to obtain the URN from an existing secret and pass it to Secret::new to create a second unbound instance. Each subsystem then binds its own copy.
use Secret;
let original: = new?;
// Subsystem B gets its own unbound secret with the same URN.
let for_subsystem_b: = new?;
Note that this creates two independent bindings, so each subsystem fetches the value from the source separately. Prefer patterns 1 or 2 when a single fetch is sufficient.
Examples
Runnable examples are in the examples/ directory:
| Example | Description |
|---|---|
basic.rs |
Secret lifecycle: masked vs real value |
config.rs |
#[derive(Bindable)] with a config struct |
serde.rs |
Deserialize URNs from JSON, then bind |
file.rs |
Load TLS key and certificate with FileSource |
sharing.rs |
Share secrets across subsystems: Arc<AppConfig>, Arc<Secret<T>>, Secret::urn() |
Out of scope
- Writing secrets back to sources
- In-memory encryption