Skip to content

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

cargo new credential-tour
cd credential-tour

Add the two dependencies:

[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:

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:

cargo run
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:

MYTOOL_ANTHROPIC_KEY=from-the-environment cargo run
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 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.

cargo run
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:

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:

cargo run --bin ci
resolved: sk-ant-literal
probe: Resolved(Literal)

Now pretend to be CI, and set the fallback variable too:

CI=true ANTHROPIC_API_KEY=from-fallback cargo run --bin ci
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:

CI=1 ANTHROPIC_API_KEY=from-fallback cargo run --bin ci
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:

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