---
title: Resolve your first credential
description: Build a small program that resolves a secret through the precedence chain, watch each source take over in turn, and see the CI refusal fire.
tags:
- tutorials
---
# 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
```bash
cargo new credential-tour
cd credential-tour
```
Add the two dependencies:
```toml
[dependencies]
rtb-credentials = "0.6"
tokio = { version = "1", features = ["full"] }
```
You need `tokio` because the store methods are `async`.
## Resolve a secret from an in-memory store
Put this in `src/main.rs`:
```rust
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:
```bash
cargo run
```
```text
resolved: from-the-keychain
debug renders as: SecretBox<str>([REDACTED])
```
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:
```bash
MYTOOL_ANTHROPIC_KEY=from-the-environment cargo run
```
```text
resolved: from-the-environment
debug renders as: SecretBox<str>([REDACTED])
```
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](../explanation/precedence-chain.md) 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(())`:
```rust
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.
```bash
cargo run
```
```text
resolved: from-the-keychain
debug renders as: SecretBox<str>([REDACTED])
resolves via Keychain
```
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`:
```rust
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:
```bash
cargo run --bin ci
```
```text
resolved: sk-ant-literal
probe: Resolved(Literal)
```
Now pretend to be CI, and set the fallback variable too:
```bash
CI=true ANTHROPIC_API_KEY=from-fallback cargo run --bin ci
```
```text
error: literal credential is refused in CI environments
probe: LiteralRefusedInCi
```
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:
```bash
CI=1 ANTHROPIC_API_KEY=from-fallback cargo run --bin ci
```
```text
resolved: sk-ant-literal
probe: Resolved(Literal)
```
`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:
```bash
CI=false cargo run --bin ci
```
## 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](../how-to/add-a-credential-to-your-config.md)
— do this properly, with a typed config struct.
- [`CredentialRef` configuration keys](../reference/credential-ref.md)
— every key, its type, and what happens when it's wrong.
- [What this crate does not do](../explanation/limitations.md) — the
constraints worth knowing before you design around it.