Skip to content
Guide

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 .env or 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 -A after touching .gitignore, or builds .env.example by 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 Authorization header, 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

OptionEncrypted at restVisible to an agent with shell accessHeadless / CIFits when
.env fileNoYes, cat .envYesThrowaway prototypes with test keys
export in a shell profileNoYes, printenvYesNever, for real credentials
export in one terminal sessionNoOnly in that shell treeYesShort manual sessions
direnvNo, .envrc is plaintextYes, once the directory is enteredYesPer-project config, not secrets
1Password or Bitwarden CLIYes, vendor vaultOnly via op read / bw get after sign-inWith service accountsTeams already on the vault
Cloud secret manager (AWS, GCP, Vault)YesOnly via SDK calls with cloud credentialsYes, by designProduction runtime, not laptops
OS keychain through a CLI (q-ring)Yes, OS vaultOnly via an explicit call, gated by policyNeeds a Secret Service, or the encrypted file backendDeveloper 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.

  1. Install it globally so both qring and qring-mcp land on your PATH:

    bash
    pnpm add -g @i4ctime/q-ring
    # or: npm install -g @i4ctime/q-ring
    # or: brew install i4ctime/tap/qring
  2. Check that the keychain is reachable. qring doctor writes and deletes a throwaway probe entry, confirms the audit directory is writable, and checks that qring-mcp is on PATH:

    bash
    qring doctor
  3. Store the key without the value touching shell history. Omit the value and the CLI prompts for it on stderr without echoing:

    bash
    qring set OPENAI_API_KEY

    In a script, pipe it; with non-terminal stdin, qring set reads the value to EOF:

    bash
    printf '%s' "$OPENAI_API_KEY" | qring set OPENAI_API_KEY
  4. Move existing .env files in, then delete them. qring import parses dotenv syntax, and --dry-run previews without writing:

    bash
    qring import .env --skip-existing
  5. Confirm without printing values. qring list shows names, scope and expiry; qring has KEY --quiet is the exit-code check for scripts:

    bash
    qring list
    qring has OPENAI_API_KEY --quiet && echo present
  6. Where a process needs the value, inject it rather than exporting it. qring exec places 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:

    bash
    qring 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_secrets is the path that keeps the value out of the transcript. The agent asks q-ring to run pnpm run db:migrate with DATABASE_URL injected. The MCP server spawns the child with the secret in its environment, captures stdout and stderr, runs both through the same redaction transform as qring exec, and returns the exit code plus scrubbed output. The model sees Exit code: 0 and the migration log, never the connection string. The default restricted profile also refuses to run curl, python, sh and other binaries that could forward the injected environment elsewhere.
  • has_secret and inspect_secret answer "does it exist" and "when does it expire" without returning the value. list_secrets returns names and metadata only.
  • get_secret does 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: deniedKeys and deniedTags under policy.mcp in .q-ring.json block it outright, and a key stored with --requires-approval waits until you run qring 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 2592000 marks the key stale at 75 percent of its lifetime and refuses reads once it expires; qring health lists both, plus any access anomalies.
  • Review who read what. qring audit --key OPENAI_API_KEY shows the timeline; qring audit:sessions folds it per MCP client; qring audit:verify confirms the log was not edited or truncated.
  • Check liveness before you debug. qring validate OPENAI_API_KEY sends one authenticated request to the provider, key in a header, and reports valid, invalid or error.
  • Rotate where the provider allows it. qring rotate KEY goes 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:

bash
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.