Where to store API keys for Claude Code, Cursor and Codex
Once a coding agent can read your filesystem and run commands, a plaintext .env is one tool call away from the transcript. Here is what to do instead, and how the keychain route works end to end.
· 9 min read
Why .env files stop being safe once an agent can read them
A .env file was a reasonable compromise when the only thing reading it was your own process. It is plaintext on disk, but it is gitignored and nothing else looks at it. That assumption breaks the moment you give a coding agent filesystem and shell access. Claude Code, Cursor's agent mode and Codex read files to build context and run commands to check their work, and both activities turn a plaintext credential into something that travels.
The leak paths are concrete:
- The agent reads it into the prompt. Asked to figure out why an OpenAI call fails, the agent runs
cat .envor opens the file in its editor tool. The value is now in the conversation, in the provider's logs, and in the editor's session history on disk. - It gets committed. An agent that runs
git add -Aafter touching.gitignore, or builds.env.exampleby copying the real file without blanking the values, pushes the key to the remote. - It appears in a tool result.
printenv,env | grep KEY, a failing test that prints its config, a debug log line with request headers: each returns stdout to the model, and stdout becomes transcript. - It ends up in a bug report. Agents are good at drafting issues. A stack trace that includes an
Authorizationheader, pasted into GitHub by a helpful assistant, is a public key.
Shell profiles (export OPENAI_API_KEY=... in .zshrc) are worse, not better. Every process you start inherits the value, including the agent and each subprocess it spawns, and printenv lists it on demand. You have replaced a file the agent might read with an environment the agent definitely has.
What the OS keychain gives you
Every desktop OS ships a credential store that solves a different problem than a file: it holds secrets encrypted at rest and hands them only to a running process that asks, after your login session has opened the store.
- macOS Keychain. Items live in
~/Library/Keychains/login.keychain-db, encrypted with a key derived from your login password. Each item carries an access control list. When an application other than the one that created the item asks for it, macOS shows a prompt and you choose allow once, always allow, or deny. - Windows Credential Manager. Credentials are protected by DPAPI, which ties the encryption key to your Windows account. Another user on the same machine cannot decrypt them, and neither can a copy of the disk without your credentials.
- Linux Secret Service. A D-Bus API implemented by GNOME Keyring, KDE's
ksecretservice, or KeePassXC. The default collection is stored encrypted under~/.local/share/keyrings/and opens with your login through PAM. Any process in your session can request an item, so isolation is per user, not per application.
None of this stops a process running as you from reading a value, as q-ring's threat model states plainly. What it stops is the accidental paths above: nothing to cat, nothing to commit, nothing sitting in printenv. The secret exists as plaintext only in the memory of the process that asked for it.
The realistic options, compared
| Option | Encrypted at rest | Visible to an agent with shell access | Headless / CI | Fits when |
|---|---|---|---|---|
.env file | No | Yes, cat .env | Yes | Throwaway prototypes with test keys |
export in a shell profile | No | Yes, printenv | Yes | Never, for real credentials |
export in one terminal session | No | Only in that shell tree | Yes | Short manual sessions |
| direnv | No, .envrc is plaintext | Yes, once the directory is entered | Yes | Per-project config, not secrets |
| 1Password or Bitwarden CLI | Yes, vendor vault | Only via op read / bw get after sign-in | With service accounts | Teams already on the vault |
| Cloud secret manager (AWS, GCP, Vault) | Yes | Only via SDK calls with cloud credentials | Yes, by design | Production runtime, not laptops |
| OS keychain through a CLI (q-ring) | Yes, OS vault | Only via an explicit call, gated by policy | Needs a Secret Service, or the encrypted file backend | Developer machines running agents |
direnv deserves a note because it is often recommended here. It loads per-directory configuration well, but .envrc is plaintext and its exports become ambient in every process started from that directory, the property you are trying to remove. Password-manager CLIs are a good answer if your team already runs one, at the cost of a network round trip and a vendor session that also needs guarding. Cloud managers are the right home for production runtime secrets and the wrong tool for the key your editor needs on a laptop.
The OS keychain sits in the middle: local, encrypted, already installed, no account. What it lacks is one interface across three operating systems and any notion of an agent. That is the gap a wrapper like q-ring fills.
Step by step: the keychain route with q-ring
q-ring is an AGPL-3.0 CLI plus MCP server. It writes each secret as a small JSON envelope into the OS keychain through @napi-rs/keyring, under a service name such as q-ring:global or q-ring:project:<hash>, so the entries are visible in Keychain Access or Seahorse.
-
Install it globally so both
qringandqring-mcpland on yourPATH:bashpnpm add -g @i4ctime/q-ring # or: npm install -g @i4ctime/q-ring # or: brew install i4ctime/tap/qring -
Check that the keychain is reachable.
qring doctorwrites and deletes a throwaway probe entry, confirms the audit directory is writable, and checks thatqring-mcpis onPATH:bashqring doctor -
Store the key without the value touching shell history. Omit the value and the CLI prompts for it on stderr without echoing:
bashqring set OPENAI_API_KEYIn a script, pipe it; with non-terminal stdin,
qring setreads the value to EOF:bashprintf '%s' "$OPENAI_API_KEY" | qring set OPENAI_API_KEY -
Move existing
.envfiles in, then delete them.qring importparses dotenv syntax, and--dry-runpreviews without writing:bashqring import .env --skip-existing -
Confirm without printing values.
qring listshows names, scope and expiry;qring has KEY --quietis the exit-code check for scripts:bashqring list qring has OPENAI_API_KEY --quiet && echo present -
Where a process needs the value, inject it rather than exporting it.
qring execplaces the secrets in the child's environment and filters the child's stdout and stderr, replacing any known secret value before it reaches your terminal:bashqring exec -- pnpm test
How the secret reaches an agent without landing in the transcript
Wire qring-mcp into your editor (qring setup cursor, qring setup kiro or qring setup claude writes the MCP config) and the agent gets a set of tools rather than a file. Which tool it calls decides whether the value ever reaches the model.
exec_with_secretsis the path that keeps the value out of the transcript. The agent asks q-ring to runpnpm run db:migratewithDATABASE_URLinjected. The MCP server spawns the child with the secret in its environment, captures stdout and stderr, runs both through the same redaction transform asqring exec, and returns the exit code plus scrubbed output. The model seesExit code: 0and the migration log, never the connection string. The defaultrestrictedprofile also refuses to runcurl,python,shand other binaries that could forward the injected environment elsewhere.has_secretandinspect_secretanswer "does it exist" and "when does it expire" without returning the value.list_secretsreturns names and metadata only.get_secretdoes return the value, as{ ok, data: { key, value } }, because sometimes the agent needs it. That read is a deliberate tool call rather than a file the agent happened upon, and it goes through key-level policy first:deniedKeysanddeniedTagsunderpolicy.mcpin.q-ring.jsonblock it outright, and a key stored with--requires-approvalwaits until you runqring approve KEY --for 3600 --reason "...". Every read lands in a hash-chained audit log stamped with the client's name.
The honest limit: once get_secret has returned a value to a client that also holds a network tool, no local secrets manager can take it back. Deny production keys to get_secret and expose them only through exec_with_secrets.
Rotation and audit habits
Storage is half the job. A few habits keep a leaked key from mattering for long:
- Give keys a lifetime.
qring set KEY --ttl 2592000marks the key stale at 75 percent of its lifetime and refuses reads once it expires;qring healthlists both, plus any access anomalies. - Review who read what.
qring audit --key OPENAI_API_KEYshows the timeline;qring audit:sessionsfolds it per MCP client;qring audit:verifyconfirms the log was not edited or truncated. - Check liveness before you debug.
qring validate OPENAI_API_KEYsends one authenticated request to the provider, key in a header, and reports valid, invalid or error. - Rotate where the provider allows it.
qring rotate KEYgoes through the provider's rotation API when one exists, and entangled keys pick up the new value together.
Troubleshooting
Keychain locked on headless Linux or over SSH. There is no D-Bus session and no keyring daemon, so qring set fails with a Secret Service error. Install gnome-keyring and start it inside a D-Bus session around your command:
dbus-run-session -- sh -c 'echo "" | gnome-keyring-daemon --unlock; exec qring list'An empty daemon password leaves the keyring file unencrypted; use it only on disposable hosts. For a machine that will never have a desktop session, opt into the encrypted file backend explicitly: QRING_BACKEND=file QRING_FILE_PASSPHRASE=... qring set KEY. It is AES-256-GCM under a PBKDF2-derived key, and it never activates on its own.
CI with no keychain at all. Let the CI system's own secret store inject values into the job, and use q-ring for validation rather than storage: qring ci:validate --json checks that the declared secrets are present and live. The file backend works in CI too, with the passphrase supplied as a CI secret.
The agent asks for the raw value. An SDK that only takes a key as a constructor argument is the usual reason. Prefer exec_with_secrets so the SDK receives it through process.env inside a child process. If the value genuinely has to reach the model, approve it for a bounded window with qring approve KEY --for 900 --reason "...", watch qring audit, and rotate afterwards. If the agent keeps asking for keys it has no reason to use, that is what the audit log is for.
Next steps
Frequently asked questions
- Is a .env file safe if it is in .gitignore?
- Against accidental commits by you, mostly. Against an agent with shell access, no: .gitignore does not stop cat, printenv, a test that prints its config, or git add -A after the ignore file itself was edited. The file is still plaintext on disk and readable by any process running as you.
- Does the agent ever see the secret value with q-ring?
- Only if it calls get_secret and policy allows it. exec_with_secrets injects the value into a child process and redacts it from the captured output, has_secret and inspect_secret return metadata only, and deniedKeys or --requires-approval block get_secret entirely or until you approve. Every read is written to a hash-chained audit log.
- What about a machine with no keychain, like a CI runner?
- Use the CI system's own secret store to inject values into the job and keep q-ring for validation, or opt into the encrypted file backend with QRING_BACKEND=file and QRING_FILE_PASSPHRASE. The file backend is AES-256-GCM, must be chosen explicitly, and fails closed without the passphrase.
- Is q-ring free, and does it phone home?
- It is AGPL-3.0 with no paid tier, no account and no cloud. The CLI and MCP server make no network calls of their own; the only outbound requests are the ones you trigger explicitly, such as qring validate against a provider you name.