@williamthorsen/toolbelt.secrets
v0.3.0
Published
Utilities for storing and retrieving secrets in the OS credential store
Downloads
296
Maintainers
Readme
@williamthorsen/toolbelt.secrets
Utilities for storing and retrieving secrets in the OS credential store.
Release notes — v0.3.0 (2026-09-15)
🎉 Features
🚨 Breaking: Move createTempTree from toolbelt.filesystem to toolbelt.testing (#313)
- Moves
CreateTempTreeOptionsand theTempTreehandle to@williamthorsen/toolbelt.testing/candidatealong withcreateTempTree.
Migration: Import
createTempTree,CreateTempTreeOptions, andTempTreefrom@williamthorsen/toolbelt.testing/candidate, and declare@williamthorsen/toolbelt.testingin the manifest that declared@williamthorsen/toolbelt.filesystemfor them.- Moves
Installation
pnpm add @williamthorsen/toolbelt.secretsRequires Node.js 24 or later, and macOS: The one backend is the macOS keychain, reached through /usr/bin/security. Constructing a store on another platform throws, naming the platform, rather than failing later at the first call.
How a secret is stored
An item is named by a service and an account. The service is the caller's own name for the secret, never derived from a host or a URL, so one item can serve every product that accepts the same token. The account is optional and defaults to the empty account, which is matched exactly: A lookup on a service holding several accounts returns the one that is asked for, rather than an arbitrary one.
A secret reaches security through its interactive mode, which takes a whole command on stdin, so the secret never sits in an argument vector that any local process could read. It travels as hexadecimal, which encodes every byte, a line break included, and every write is read back and compared before it is reported as stored.
One command line contains the secret together with the service, the account, and the keychain, and security reads at most 4,095 bytes of it. That leaves room for a secret of roughly 2,000 bytes, and the exact ceiling falls as the other three grow. A secret that would not fit is refused, naming the room left; none is ever stored in part.
An item created here is local to the Mac that created it. security offers no iCloud Keychain synchronization, so a secret stored on one machine has to be stored again on the next.
CLI
The package ships a tb-secret command exposing the store to a shell caller.
pnpm add --global @williamthorsen/toolbelt.secrets # puts tb-secret on PATH
npx @williamthorsen/toolbelt.secrets get my-token # or run it without installingtb-secret --help, each subcommand's --help, and tb-secret --version report the surface and the installed version.
| Subcommand | Effect |
| ---------------------------- | ---------------------------------------------------- |
| tb-secret delete <service> | Removes a secret |
| tb-secret get <service> | Prints a secret |
| tb-secret has <service> | Reports whether a secret is stored, printing nothing |
| tb-secret set <service> | Stores a secret |
| Option | Effect |
| ----------------------- | ------------------------------------------------------- |
| -a, --account <name> | Account holding the secret (default: the empty account) |
| -k, --keychain <path> | Keychain to act on, rather than the default search list |
At a terminal, set prompts for the secret twice and echoes nothing; piped, it reads stdin and drops one trailing newline, since echo adds one. The secret passes through this process either way.
tb-secret set atlassian-api-token --account [email protected] # prompts, echoing nothing
pbpaste | tb-secret set atlassian-api-token # or pipe it
export ATLASSIAN_API_TOKEN=$(tb-secret get atlassian-api-token --account [email protected])Exit codes
| Code | Meaning |
| ---- | ------------------------------------------------------------- |
| 0 | The command succeeded |
| 1 | No secret is stored under that service and account |
| 2 | Usage or validation error, with the message on stderr |
| 3 | The keychain could not be reached, with the message on stderr |
An absent secret is 1 and a keychain that could not be reached is 3, so a script can tell one from the other.
if token=$(tb-secret get atlassian-api-token); then
curl --user "[email protected]:$token" https://example.atlassian.net/rest/api/3/myself
elif [ $? -eq 1 ]; then
echo 'No token stored. Run `tb-secret set atlassian-api-token`.' >&2
ficreateKeychainStore
createKeychainStore(options?: { keychain: string }): WritableSecretStore;
interface SecretQuery {
readonly account?: string | undefined;
readonly service: string;
}
interface SecretStore {
deleteSecret(query: SecretQuery): boolean;
findSecret(query: SecretQuery): string | undefined;
hasSecret(query: SecretQuery): boolean;
}
interface WritableSecretStore extends SecretStore {
setSecret(query: SecretQuery, secret: string): void;
}Opens the macOS keychain as a secret store. Every call is synchronous.
import { createKeychainStore } from '@williamthorsen/toolbelt.secrets/candidate';
const store = createKeychainStore();
store.setSecret({ account: '[email protected]', service: 'atlassian-api-token' }, token);
store.findSecret({ account: '[email protected]', service: 'atlassian-api-token' });
// the tokenfindSecret returns undefined where no item is stored, and throws where the keychain could not be reached, so absence is never confused with a failure. deleteSecret reports whether an item was there to remove.
hasSecret reads the item's attributes rather than its data. That is the difference worth knowing: Retrieving a secret can raise a keychain access prompt where the item was created by another program, and an attribute lookup cannot.
setSecret rejects an empty secret, which the keychain would hold as an item indistinguishable from a stray one, and one too long for the command line that contains it. Both are UnstorableSecretError, which is exported: Nothing was attempted, so a caller can tell a value that the keychain cannot store from a keychain that it could not reach. Every other secret is stored and returned byte for byte, whatever it holds. Each write is read back and compared, so a secret that did not survive the round trip fails at the write rather than at a later caller. That readback retrieves the secret, so replacing an item created by another program can raise the keychain access prompt described above, and a write whose readback is refused is reported as unverified rather than as stored.
const projectStore = createKeychainStore({ keychain: '/Users/me/Library/Keychains/project.keychain-db' });
projectStore.findSecret({ service: 'deploy-key' });A named keychain accepts a write like the default search list does. SecretStore remains the read-only half of the surface, for a backend that accepts no new secret.
promptSecret
promptSecret(input: NodeJS.ReadableStream, output: NodeJS.WritableStream): Promise<string>;Reads a secret from a terminal without echoing it, asking twice and comparing, since nothing on screen shows what was typed. It rejects where the two entries differ, and where the input ends before a secret is entered, which is the one event that Ctrl-C, Ctrl-D, and a closed stream all share.
import { promptSecret } from '@williamthorsen/toolbelt.secrets/candidate';
const secret = await promptSecret(process.stdin, process.stderr);The prompts go to output and the line being edited does not, so a caller passing process.stderr leaves stdout free for the command's own result. security has a prompt of its own, but it fills a 128-byte buffer and hands back nothing to verify, which is why this reads the secret instead.
