---
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.88 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)
does this properly, with a typed config struct.
- [`CredentialRef` configuration keys](../reference/credential-ref.md)
lists every key, its type, and what happens when it's wrong.
- [What this crate does not do](../explanation/limitations.md) covers the
constraints worth knowing before you design around it.