Paste a key once. Every directory that needs it afterwards gets one card, then silence — in any agent.
tokenstash is a local credential broker for coding agents. The agent runs one command instead of stalling on Please paste your API key; keys you already own are injected into the project's env file, keys you don't get you a link and a desktop notification. Secret values never enter the agent's context, its output, or any log.
Download · Provider registry · Verification record
Coding agents stall on the same line every new project:
Please provide: OPENAI_API_KEY= RESEND_API_KEY= STRIPE_SECRET_KEY=
tokenstash gives the agent one command instead. If the key is already in your stash it is written into the project's env file instantly. If it isn't, the agent shows you a link (and your desktop gets a notification); you click it, follow it to the vendor's own page, paste once, and the agent resumes. The second project that needs that key shows you one card naming the directory and the file; after that it never involves you.
agent › tokenstash need OPENAI_API_KEY RESEND_API_KEY TAVUS_API_KEY
✓ OPENAI_API_KEY injected → .env.local
✓ RESEND_API_KEY injected → .env.local
⏳ TAVUS_API_KEY pending — task t_7fa2 → http://127.0.0.1:7433/t/t_7fa2?t=…
Works with Claude Code, Codex and Cursor (each measured by the conformance harness), with Gemini CLI also registered by init, and with anything that can run a shell command. MIT, local-only, no accounts, no telemetry, never in the request path.
| Step | What you do | tokenstash output |
|---|---|---|
| Init | tokenstash init |
Keychain backend chosen, MCP server registered with every agent found, skill file installed |
| Ask | The agent needs a key it has never seen | Task filed, desktop notification, localhost inbox link printed for the chat |
| Paste | You open the vendor page and paste once | Pattern checked, liveness probed, stored in the OS keychain |
| Inject | Nothing — the agent re-runs the same command | Written to .env.local, 0600, .gitignore enforced, exit 0 |
| Reuse | A different project, a different agent, next week | One pairing card the first time that directory asks for stored keys — you see exactly which keys go into which file: Allow these, Allow these + any non-sensitive key here (for that identity), or Deny (remembered for a day). Silent there from then on |
flowchart LR
Need["tokenstash need NAME"] --> Hit{"in your stash?"}
Hit -->|yes| Grant{"paired into this directory?"}
Grant -->|yes| Verify["re-checked with the provider"]
Grant -->|first time| Pair["one pairing card: these keys → this file"]
Pair --> Verify
Verify --> Env["written to .env.local — exit 0"]
Hit -->|no| Task["task filed"]
Task --> Notify["desktop notification + inbox link"]
Notify --> Paste["you sign up and paste, once"]
Paste --> Keychain["OS keychain"]
Keychain --> Env
| Layer | Stack | Role |
|---|---|---|
| CLI | Rust 2021, clap 4 | need, ask, report-bad, tasks and run are the product surface an agent touches; everything else is for a person |
| Stash | keyring 3, OS-native |
macOS Keychain, Linux Secret Service, kernel-keyring fallback (the crates build on macOS and Linux only; Windows is not supported yet); insecure-file is CI-only and warns |
| Index | SQLite (rusqlite, bundled) |
Names, identities, projects, grants, tasks, audit log — metadata only, never a value |
| Inbox | tiny_http bound to 127.0.0.1 |
Two-scope session tokens, HttpOnly; SameSite=Strict cookie, CSRF double-submit, empty 404 for anything unauthenticated (the one open route, /verify, answers an ownership challenge and nothing else) |
| MCP | stdio JSON-RPC server | secrets_request, secrets_list, secrets_report_invalid, human_request, task_check, task_list |
| Registry | crates/core/registry/providers.json |
79 providers: signup URL, ordered steps, key pattern, optional liveness check |
| Validation | validate.rs over ureq |
Prefix check at paste time, cheap liveness call, reject_status for providers that do not spell auth failure 401 |
| Portability | argon2 + chacha20poly1305 | export / import: one passphrase-encrypted bundle moves a stash between machines |
| Release | GitHub Actions on v* tags |
Four platform binaries, sha256 sidecars, Homebrew formula |
flowchart TD
Agent["Agent — CLI or MCP"] --> Need["need NAME"]
Need --> Trust["Workspace grants: paired once per directory"]
Trust --> Stash["OS keychain"]
Stash --> Check["Verify-on-use: re-check with the provider"]
Check --> Envfile["Env file, 0600, gitignored"]
Need --> Tasks["Task queue"]
Tasks --> Inbox["Localhost inbox, session-gated"]
Inbox --> Human["You — vendor page, paste once"]
Human --> Stash
Need --> Audit["Append-only audit log"]
Redact["Value-free by construction: CLI output, MCP results, SQLite, audit, errors"] -.-> Envfile
Redact -.-> Audit
Redact -.-> Inbox
| Question | tokenstash answer |
|---|---|
| Can an agent get a credential without pasting it into a chat? | Yes. need is the only path, and it returns an exit code, not a value. Values go your paste → keychain → env file and are never rendered anywhere the model can read them. |
| Does "never leaks" mean anything mechanically? | Yes. scripts/leak-test.sh drives the real binary with a canary secret and asserts the canary appears in none of stdout, stderr, the SQLite index, the config, the audit log, or MCP output — a black-box test, not an inspection of the code that was supposed to redact. |
| Is loopback treated as authentication? | No, and that is the point. Every inbox request needs a 32-byte session token that arrives as ?t= on the link you click and becomes an HttpOnly; SameSite=Strict cookie. Anything without one gets an empty 404. |
| Can a model approve its own request? | Not with anything tokenstash hands it. The link an agent prints carries the paste-scope token: enough to answer a missing-key card, not enough to approve, to close another directory's card, or to paste a value that other directories would receive. The full-scope token reaches you only through channels you trigger — the notification, tokenstash open, your terminal. Every command that widens reach (answer --allow, open, forget, list, audit, tasks --all, need --force, rotate, check, bind, workspaces, export, import, and the agent registration in init) refuses unless both streams are a terminal and no agent marker is set. That check is a heuristic. An agent that allocates a pseudo-terminal and scrubs its environment, or that simply reads your keychain and config as your user, is outside what tokenstash can stop: it defends the line between the agent's tools and you, not the line between processes running as you (SECURITY.md). |
| Can a hostile repo get a key delivered just by asking for it? | No. Nothing is trusted by folder. The first time a directory asks for a stored key you see one card naming the directory, the file, and every key with its sensitivity — approve exactly those, "these plus any non-sensitive registry key here", or deny. A paste grants one key to one directory; sensitive and unregistered keys need their own yes per directory; a program's own output choosing a key asks every time. A copy that carries its own .env.local with the same value needs no card and gains no grant (non-sensitive registry keys; file yours, untracked, not a symlink). The MCP server binds one directory at startup — the one your agent opened — refuses to act for any other, and refuses /, your home, tool and shared temp dirs outright. tokenstash workspaces lists and revokes it all (values already written stay written); every delivery is audited with the grant that allowed it. |
| Can a revoked key be caught before the agent burns a turn on a 401? | Usually. A key unchecked for a day is re-verified with its provider before delivery — one free, read-only request to the host your code already calls. A dead key becomes a "Replace" card; a provider outage just delivers the key unchecked. |
| Is the provider registry actually true? | It was checked, row by row, by HTTP request rather than recollection — 18 dead URLs fixed, 5 checks corrected, 1 removed for being decoration, and the rows that could not be settled say so. The record is docs/registry-verification.md; reproduce it with scripts/verify-registry.py. |
The guarantees, what is out of scope, and how to report something: SECURITY.md. Two limits worth knowing before you install: an agent with a shell in a paired directory can read that directory's env file — delivery is that — and the desktop notification carries a full-scope inbox link, so your notification history is as trusted as your terminal (notifications = false turns it off).
Existing per-project approvals become grants automatically — for directories that still exist and were not re-created since. trust_roots in config.toml stop applying, so a project that was silent only because of a root shows one pairing card (unless its .env.local already holds the value); tokenstash trust rm DIR tidies the old list. need/ask lost --project (the directory you run them in is the project), the MCP tools lost their project argument, and tokenstash workspaces replaces trust. A 0.1 binary can still open the upgraded database. Details in CHANGELOG.md.
npm install -g tokenstash # or: bun add -g tokenstash · pnpm add -g tokenstash
brew install kgarg2468/tokenstash/tokenstash
uv tool install tokenstash # or: pipx install tokenstash
tokenstash init # macOS and Linux; Windows is not supported yetThe npm package is a launcher plus one prebuilt binary package per platform (optionalDependencies, no install scripts — bun and pnpm install it as-is). The PyPI wheels carry the same binary and no Python code. Prebuilt binaries for macOS (arm64, x64) and Linux (x64, arm64; static, any distribution), with sha256 sidecars, are attached to the latest release. From source: cargo install --git https://github.com/kgarg2468/tokenstash tokenstash.
init picks a keychain backend and registers the MCP server with the agents it finds (Claude Code, Codex, Cursor, Gemini CLI); Claude Code also gets a skill file and Codex an AGENTS.md snippet, so those two reach for it unprompted. It trusts no folder: directories pair once. tokenstash init --undo takes every registration back out.
- You create every account. tokenstash gets you to the right page with the right steps; it never signs up for you, never proxies an API, never reads another tool's credential store.
- Generated secrets belong to one project.
JWT_SECRETand friends are created by tokenstash, not pasted, and each directory gets its own — one application can never sign with another's key. If two directories must share one, paste it into both. - Directories pair once. The first time a directory asks for keys you already have, one card shows exactly which keys would go into which file; approve, and that directory is silent from then on. Keys tagged sensitive (live Stripe, AWS, service-role) and keys the registry does not know get their own card per directory; the broad button never covers them. Deny is remembered for a day. A directory deleted and re-created at the same path pairs again (
tokenstash workspacesflags it). A copy that already carries the same value in its own.env.localneeds no card for non-sensitive registry keys — the file must be yours, untracked and not a symlink; a wrong value there means a card, and no comparison for a day. Nothing is delivered — by CLI or MCP — into/, your home,/tmp, anything under tool or credential directories (~/.ssh,~/.aws,~/.local,~/.claude, …) or the directory holding the stash itself. - Local secrets are generated, not requested.
AUTH_SECRET,JWT_SECRET,SESSION_SECRETnever involve a human. - Verification is tunable.
verify_every = "24h" | "1h" | "always" | "never"inconfig.toml;alwaysis still at most once a minute per key. Probes that would cost quota, or whose provider cannot distinguish a bad key from a bad request, are never run unattended.
config.toml lives in ~/.config/tokenstash/ (macOS: ~/Library/Application Support/tokenstash/), or wherever TOKENSTASH_HOME points. Every key is optional.
| key | default | |
|---|---|---|
env_file |
.env.local |
the file keys are written to, relative to the project root; one setting for every project |
inbox_port |
7433 |
|
task_ttl_hours |
24 |
how long a card stays open, and how long a denial is remembered |
stash_backend |
auto |
keyring (OS store), keyutils (Linux kernel keyring: survives logout, not reboot), insecure-file (plaintext, 0600, warns on every run; CI only). TOKENSTASH_STASH overrides it |
notifications |
true |
desktop notifications; they carry a full-scope inbox link |
inbox_links |
paste |
full makes the link agents print full-scope too — for a headless setup with no notification channel, at the cost of the guarantee above |
verify_every |
24h |
<n>h, <n>m, always (still at most once a minute per key), never |
The project is the git root. need resolves the directory it runs in to the nearest git checkout you own and writes the env file there, so in a monorepo apps/web and apps/api share repo/.env.local — that is the file your framework must load. A directory that is not a checkout is its own project.
No browser, no desktop? Over SSH the inbox link is on the remote's loopback: forward port 7433, or answer from the terminal with tokenstash answer. In a container with no keyring, set TOKENSTASH_STASH=insecure-file (plaintext) or run tokenstash on the host.
Uninstall: tokenstash init --undo removes what init wrote outside its own directory; tokenstash forget NAME removes a key from the keychain; delete the config directory for the rest.
tokenstash need NAME… [--why] [--url] [--step …] [--blocking] |
exit 0 injected · 10 pending · 20 denied · 30 expired · 1 error. --force re-asks after a denial and is for a person at a terminal |
tokenstash ask "title" [--url] [--step …] [--expects confirm|text] |
non-secret human task (DNS, dashboard toggle, OAuth consent) |
tokenstash answer [id] [--stdin] [--allow] [--deny] |
answer from the terminal instead of the inbox |
tokenstash tasks [--all] [--history] · tokenstash open |
what is waiting on you |
tokenstash list · forget NAME · rotate NAME · bind NAME --identity work |
manage the stash (never shows values); bind after the directory has paired |
tokenstash check · report-bad NAME --status 401 |
prove keys are live; tell tokenstash when a provider rejects one |
tokenstash export · import |
passphrase-encrypted bundle, to move a stash between machines |
tokenstash workspaces [list|revoke DIR|forget DIR] |
which directories are paired with which keys; take a directory's grants away (values already written stay). For a person at a terminal — agents cannot list it |
tokenstash run -- npm run dev |
zero-config shim: dies on a missing registry-known key → asks → restarts. Every key a program's output asks for gets its own yes, each run — never a standing grant |
tokenstash init [--undo] · mcp · inbox · doctor · audit · registry |
Not a vault — use 1Password or Infisical; backends for them are a later step. Not a proxy: tokenstash is never in the request path, and no traffic of yours flows through it. Not discovery: it never reads gh, aws, Claude Code or Codex auth state. Not a sandbox: the agent can still read .env.local, exactly as it can today. What it removes is the casual leak — the paste into chat, the key echoed back in a summary — and run -- adds a fresh human yes for every key a program's own output asks for.
crates/core/registry/providers.json — one JSON object per key: name, provider, signup URL, ordered steps, key pattern, optional liveness check. PRs welcome; that file is the whole product's breadth. See CONTRIBUTING.md and the verification standard in docs/registry-verification.md.
MIT