diff --git a/Cargo.lock b/Cargo.lock
index 0d997e2c215..9642569fa11 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -339,6 +339,7 @@ dependencies = [
  "anyhow",
  "serde",
  "serde_json",
+ "snapbox",
  "thiserror",
  "time",
 ]
@@ -867,6 +868,18 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "escargot"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "768064bd3a0e2bedcba91dc87ace90beea91acc41b6a01a3ca8e9aa8827461bf"
+dependencies = [
+ "log",
+ "once_cell",
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "fastrand"
 version = "1.9.0"
@@ -3003,6 +3016,7 @@ dependencies = [
  "anstyle",
  "content_inspector",
  "dunce",
+ "escargot",
  "filetime",
  "normalize-line-endings",
  "similar",
diff --git a/credential/cargo-credential/Cargo.toml b/credential/cargo-credential/Cargo.toml
index 9ac9168ffe6..b765d529e53 100644
--- a/credential/cargo-credential/Cargo.toml
+++ b/credential/cargo-credential/Cargo.toml
@@ -12,3 +12,6 @@ serde = { workspace = true, features = ["derive"] }
 serde_json.workspace = true
 thiserror.workspace = true
 time.workspace = true
+
+[dev-dependencies]
+snapbox = { workspace = true, features = ["examples"] }
diff --git a/credential/cargo-credential/examples/file-provider.rs b/credential/cargo-credential/examples/file-provider.rs
new file mode 100644
index 00000000000..d119585360d
--- /dev/null
+++ b/credential/cargo-credential/examples/file-provider.rs
@@ -0,0 +1,90 @@
+//! Example credential provider that stores credentials in a JSON file.
+//! This is not secure
+
+use cargo_credential::{
+    Action, CacheControl, Credential, CredentialResponse, RegistryInfo, Secret,
+};
+use std::{collections::HashMap, fs::File, io::ErrorKind};
+type Error = Box<dyn std::error::Error + Send + Sync + 'static>;
+
+struct FileCredential;
+
+impl Credential for FileCredential {
+    fn perform(
+        &self,
+        registry: &RegistryInfo,
+        action: &Action,
+        _args: &[&str],
+    ) -> Result<CredentialResponse, cargo_credential::Error> {
+        if registry.index_url != "https://github.com/rust-lang/crates.io-index" {
+            // Restrict this provider to only work for crates.io. Cargo will skip it and attempt
+            // another provider for any other registry.
+            //
+            // If a provider supports any registry, then this check should be omitted.
+            return Err(cargo_credential::Error::UrlNotSupported);
+        }
+
+        // `Error::Other` takes a boxed `std::error::Error` type that causes Cargo to show the error.
+        let mut creds = FileCredential::read().map_err(cargo_credential::Error::Other)?;
+
+        match action {
+            Action::Get(_) => {
+                // Cargo requested a token, look it up.
+                if let Some(token) = creds.get(registry.index_url) {
+                    Ok(CredentialResponse::Get {
+                        token: token.clone(),
+                        cache: CacheControl::Session,
+                        operation_independent: true,
+                    })
+                } else {
+                    // Credential providers should respond with `NotFound` when a credential can not be
+                    // found, allowing Cargo to attempt another provider.
+                    Err(cargo_credential::Error::NotFound)
+                }
+            }
+            Action::Login(login_options) => {
+                // The token for `cargo login` can come from the `login_options` parameter or i
+                // interactively reading from stdin.
+                //
+                // `cargo_credential::read_token` automatically handles this.
+                let token = cargo_credential::read_token(login_options, registry)?;
+                creds.insert(registry.index_url.to_string(), token);
+
+                FileCredential::write(&creds).map_err(cargo_credential::Error::Other)?;
+
+                // Credentials were successfully stored.
+                Ok(CredentialResponse::Login)
+            }
+            Action::Logout => {
+                if creds.remove(registry.index_url).is_none() {
+                    // If the user attempts to log out from a registry that has no credentials
+                    // stored, then NotFound is the appropriate error.
+                    Err(cargo_credential::Error::NotFound)
+                } else {
+                    // Credentials were successfully erased.
+                    Ok(CredentialResponse::Logout)
+                }
+            }
+            // If a credential provider doesn't support a given operation, it should respond with `OperationNotSupported`.
+            _ => Err(cargo_credential::Error::OperationNotSupported),
+        }
+    }
+}
+
+impl FileCredential {
+    fn read() -> Result<HashMap<String, Secret<String>>, Error> {
+        match File::open("cargo-credentials.json") {
+            Ok(f) => Ok(serde_json::from_reader(f)?),
+            Err(e) if e.kind() == ErrorKind::NotFound => Ok(HashMap::new()),
+            Err(e) => Err(e)?,
+        }
+    }
+    fn write(value: &HashMap<String, Secret<String>>) -> Result<(), Error> {
+        let file = File::create("cargo-credentials.json")?;
+        Ok(serde_json::to_writer_pretty(file, value)?)
+    }
+}
+
+fn main() {
+    cargo_credential::main(FileCredential);
+}
diff --git a/credential/cargo-credential/src/lib.rs b/credential/cargo-credential/src/lib.rs
index bbd437617aa..02564dd2a29 100644
--- a/credential/cargo-credential/src/lib.rs
+++ b/credential/cargo-credential/src/lib.rs
@@ -1,4 +1,4 @@
-//! Helper library for writing Cargo credential processes.
+//! Helper library for writing Cargo credential providers.
 //!
 //! A credential process should have a `struct` that implements the `Credential` trait.
 //! The `main` function should be called with an instance of that struct, such as:
@@ -8,6 +8,34 @@
 //!     cargo_credential::main(MyCredential);
 //! }
 //! ```
+//!
+//! While in the `perform` function, stdin and stdout will be re-attached to the
+//! active console. This allows credential providers to be interactive if necessary.
+//!
+//! ## Error handling
+//! ### [`Error::UrlNotSupported`]
+//! A credential provider may only support some registry URLs. If this is the case
+//! and an unsupported index URL is passed to the provider, it should respond with
+//! [`Error::UrlNotSupported`]. Other credential providers may be attempted by Cargo.
+//!
+//! ### [`Error::NotFound`]
+//! When attempting an [`Action::Get`] or [`Action::Logout`], if a credential can not
+//! be found, the provider should respond with [`Error::NotFound`]. Other credential
+//! providers may be attempted by Cargo.
+//!
+//! ### [`Error::OperationNotSupported`]
+//! A credential provider might not support all operations. For example if the provider
+//! only supports [`Action::Get`], [`Error::OperationNotSupported`] should be returned
+//! for all other requests.
+//!
+//! ### [`Error::Other`]
+//! All other errors go here. The error will be shown to the user in Cargo, including
+//! the full error chain using [`std::error::Error::source`].
+//!
+//! ## Example
+//! ```rust,ignore
+#![doc = include_str!("../examples/file-provider.rs")]
+//! ```
 
 use serde::{Deserialize, Serialize};
 use std::{
diff --git a/credential/cargo-credential/tests/examples.rs b/credential/cargo-credential/tests/examples.rs
new file mode 100644
index 00000000000..d31a50cb64e
--- /dev/null
+++ b/credential/cargo-credential/tests/examples.rs
@@ -0,0 +1,28 @@
+use std::path::Path;
+
+use snapbox::cmd::Command;
+
+#[test]
+fn file_provider() {
+    let bin = snapbox::cmd::compile_example("file-provider", []).unwrap();
+
+    let hello = r#"{"v":[1]}"#;
+    let login_request = r#"{"v": 1,"registry": {"index-url":"https://github.com/rust-lang/crates.io-index","name":"crates-io"},"kind": "login","token": "s3krit","args": []}"#;
+    let login_response = r#"{"Ok":{"kind":"login"}}"#;
+
+    let get_request = r#"{"v": 1,"registry": {"index-url":"https://github.com/rust-lang/crates.io-index","name":"crates-io"},"kind": "get","operation": "read","args": []}"#;
+    let get_response =
+        r#"{"Ok":{"kind":"get","token":"s3krit","cache":"session","operation_independent":true}}"#;
+
+    let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("cargo-credential-tests");
+    std::fs::create_dir(&dir).unwrap();
+    Command::new(bin)
+        .current_dir(&dir)
+        .stdin(format!("{login_request}\n{get_request}\n"))
+        .arg("--cargo-plugin")
+        .assert()
+        .stdout_eq(format!("{hello}\n{login_response}\n{get_response}\n"))
+        .stderr_eq("")
+        .success();
+    std::fs::remove_dir_all(&dir).unwrap();
+}