rtb-credentials 0.6.4

OS keychain credential storage. Part of the phpboyscout Rust toolkit.
Documentation
---
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.