Resolve your first credential¶
By the end of this you'll have a small program that resolves a secret four different ways, and you'll have watched each source win in turn. You'll also have seen the two behaviours that surprise people most: a secret that refuses to print itself, and a literal credential being turned down under CI.
Allow about fifteen minutes.
Nothing here touches a real keychain. Every step runs on a laptop, a container or a headless server, and nothing you do will be stored outside the process.
What you'll need¶
- Rust 1.82 or later, and Cargo.
- A terminal. No API keys, no accounts, no system packages.
Create the project¶
Add the two dependencies:
You need tokio because the store methods are async.
Resolve a secret from an in-memory store¶
Put this in src/main.rs:
use rtb_credentials::{
CredentialRef, CredentialStore, ExposeSecret, KeychainRef, MemoryStore, Resolver, SecretString,
};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let store = Arc::new(MemoryStore::new());
store
.set("mytool", "anthropic", SecretString::from("from-the-keychain".to_string()))
.await?;
let resolver = Resolver::new(store);
let cref = CredentialRef {
env: Some("MYTOOL_ANTHROPIC_KEY".to_string()),
keychain: Some(KeychainRef {
service: "mytool".to_string(),
account: "anthropic".to_string(),
}),
..CredentialRef::default()
};
let secret = resolver.resolve(&cref).await?;
println!("resolved: {}", secret.expose_secret());
println!("debug renders as: {secret:?}");
Ok(())
}
MemoryStore stands in for the OS keychain. It satisfies the same
CredentialStore trait the real one does, so everything you learn here
transfers — you'd swap in Resolver::with_platform_default() to use the
actual platform keychain.
Run it:
Two things just happened. The credential resolved from the keychain leg,
because MYTOOL_ANTHROPIC_KEY isn't set in your shell. And the secret
refused to print itself in debug output, even though you asked it to
directly.
That redaction isn't something you switched on. SecretString
implements Debug that way, so a secret can't fall into a log line by
being part of a struct somebody debug-printed. Getting the real value
takes the explicit expose_secret() call you can see on the line above.
Watch the environment variable take over¶
Leave the code alone and set the variable for one run:
Same config, different source. The env leg sits above the keychain in
the chain, so it wins whenever the variable is set — which is what makes
"export it for one command to test something" work.
The full order is env, then keychain, then literal, then
fallback_env. First hit wins. If you're curious why that particular
order, the explanation page makes
the case; you don't need it to finish this.
Ask where a credential comes from without printing it¶
Printing secrets to find out where they came from is a bad habit to
build. probe answers the question without handing the value back.
Add this before Ok(()):
use rtb_credentials::ResolutionOutcome;
match resolver.probe(&cref).await? {
ResolutionOutcome::Resolved(source) => println!("resolves via {source:?}"),
ResolutionOutcome::LiteralRefusedInCi => println!("literal refused under CI"),
ResolutionOutcome::Missing => println!("nothing configured resolves"),
_ => println!("unknown outcome"),
}
The wildcard arm isn't optional — ResolutionOutcome is
#[non_exhaustive], so it won't compile without one.
Worth knowing before you reach for this in a loop: probe does the same
work resolve does, including a real keychain read. It throws the
secret away rather than skipping the lookup. Against a real platform
keychain that's a round-trip per credential, and on macOS it can raise
an unlock prompt.
See a literal credential refused under CI¶
The last source is a secret written directly into config. It works locally and is deliberately refused in CI, and this is the step where that gets interesting.
Create src/bin/ci.rs:
use rtb_credentials::{CredentialRef, ExposeSecret, MemoryStore, Resolver, SecretString};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let resolver = Resolver::new(Arc::new(MemoryStore::new()));
let cref = CredentialRef {
literal: Some(SecretString::from("sk-ant-literal".to_string())),
fallback_env: Some("ANTHROPIC_API_KEY".to_string()),
..CredentialRef::default()
};
match resolver.resolve(&cref).await {
Ok(s) => println!("resolved: {}", s.expose_secret()),
Err(e) => println!("error: {e}"),
}
println!("probe: {:?}", resolver.probe(&cref).await?);
Ok(())
}
Run it normally first:
Now pretend to be CI, and set the fallback variable too:
Read that output carefully, because it's the thing most likely to bite
you later. ANTHROPIC_API_KEY was set and readable, and the resolver
did not fall back to it. A refused literal stops the chain rather
than passing the job down to the next source.
So a config carrying both a literal and a fallback doesn't mean "inline
secret locally, environment variable in CI". In CI it just fails. The
fix is to remove the literal key from whatever config your pipeline
uses.
Check what your CI platform actually sets¶
The refusal fires on one exact value. Try this:
CI=1 gets you nothing. The check compares against the lowercase string
true and nothing else — not 1, not TRUE, not True.
GitHub Actions, GitLab CI, CircleCI and Buildkite all set CI=true, so
they're covered. If you're somewhere else and you want this protection,
set CI=true in the job yourself. Nothing warns you when it's silently
inactive.
And if a literal is ever refused on your own machine because something
exported CI=true, any other value brings it back:
What you've learned¶
You resolved one credential from three different sources without changing its configuration, saw the secret refuse to print itself, and watched a literal get turned down in CI while a perfectly good fallback went unused.
That last one isn't a bug you tripped over — it's the documented behaviour, and knowing it now is worth more than the rest of this page.
Where to go next¶
- Add a credential to your tool's config — do this properly, with a typed config struct.
CredentialRefconfiguration keys — every key, its type, and what happens when it's wrong.- What this crate does not do — the constraints worth knowing before you design around it.