DEV Community

yotta
yotta

Posted on

Stop Storing LLM API Keys in Plaintext `.env` Files — Introducing LLM Key Ring (`lkr`)

TL;DR

Storing LLM API keys in .env files carries risks that have become harder to ignore in the age of AI agents. I built LLM Key Ring (lkr) — a CLI tool written in Rust that stores keys in the macOS Keychain with encryption.

  • Keychain storage — no plaintext files left on disk
  • lkr exec for env var injection — keys never touch stdout, files, or clipboard; this is the primary workflow
  • TTY guard — blocks raw key output in non-interactive environments (defense against AI agent exfiltration)

https://github.com/yottayoshida/llm-key-ring


Motivation: “It’s in .gitignore, so it’s fine” No Longer Holds

Working with LLMs means API keys pile up fast — five, ten of them sitting in a .env file before you know it.

# .env
OPENAI_API_KEY=sk-proj-...
ANTHROPIC_API_KEY=sk-ant-...
Enter fullscreen mode Exit fullscreen mode

The problem is simple: once plaintext lives “somewhere,” the attack surface expands.

  • Accidental commits (.gitignore is only as reliable as human discipline)
  • Keys leaking into shell history, command-line arguments, or logs
  • If AI agents (IDE/CLI integrations) can run local commands, prompt injection creates a path to extract secrets automatically

“Why not 1Password CLI or Doppler?” — Those are the right answer for team-level secret management. But for protecting only LLM keys on a personal dev machine, they come with overhead: external account setup, team-oriented configuration, persistent daemons. lkr depends only on the macOS Keychain — no additional service contracts, no background processes.


Usage

lkr stores keys in the macOS Keychain under service name com.llm-key-ring, using a provider:label format (e.g., openai:prod).

lkr exec — The Primary Workflow (Safest)

$ lkr exec -- python script.py
Injecting 2 key(s) as env vars:
  OPENAI_API_KEY
  ANTHROPIC_API_KEY
Enter fullscreen mode Exit fullscreen mode

This is the core of the design. Keys are retrieved from the Keychain and injected only into the child process’s environment — they never appear in stdout, files, or the clipboard.

  • openai:*OPENAI_API_KEY
  • anthropic:*ANTHROPIC_API_KEY
  • … and other major providers (see README for the full list)

To inject only specific keys:

$ lkr exec -k openai:prod -k anthropic:main -- node app.js
Enter fullscreen mode Exit fullscreen mode

One important constraint: exec injects only runtime keys. High-privilege admin keys are excluded by design (more on that below).

lkr set — Store a Key (Value Is Never Passed as a CLI Argument)

$ lkr set openai:prod
Enter API key for openai:prod: ****
Stored openai:prod (kind: runtime)
Enter fullscreen mode Exit fullscreen mode

The value is read via an interactive prompt. It is never taken as a CLI argument, which prevents exposure through shell history or ps output.

Use --force to overwrite an existing key.

lkr get — Retrieve a Key (Fallback for Manual Use)

The everyday workflow is exec. Use get only when you need to paste a key manually into something that can’t accept env vars.

$ lkr get openai:prod
Copied to clipboard (auto-clears in 30s)
  sk-p...3xYz  (runtime)   # masked display
Enter fullscreen mode Exit fullscreen mode
  • Output is masked by default
  • Clipboard is auto-cleared after 30 seconds — but only if the content hasn’t changed (verified via SHA-256 comparison, so it won’t erase something else you copied)

Use --show / --plain when you genuinely need the raw value, but note that the TTY guard applies here (see below).


Defense for the AI Agent Era: TTY Guard

The danger with AI agents isn’t that they’re “smart.” It’s that once you allow them to run local commands, extracting secrets can be automated.

lkr addresses this by restricting output in non-interactive (non-TTY) environments.

Layer 1: Block stdout (--plain / --show)

$ echo | lkr get openai:prod --plain
Error: --plain and --show are blocked in non-interactive environments.
  This prevents AI agents from extracting raw API keys via pipe.
  Use --force-plain to override (at your own risk).
# exit code 2
Enter fullscreen mode Exit fullscreen mode

Detection uses IsTerminal (isatty at the fd level). It intentionally ignores environment variables like CI or TERM — those are too easy to spoof. The trade-off is that integrated IDE terminals (pty) may pass isatty as true, which means the TTY guard can be bypassed in those environments. This is why making exec the default workflow matters so much — it doesn’t rely on output blocking at all.

Layer 2: Block clipboard copy (defense against pbpaste extraction)

In non-TTY environments, clipboard copy in get is skipped entirely.

$ echo | lkr get openai:prod
Clipboard copy skipped (non-interactive environment).
Enter fullscreen mode Exit fullscreen mode

This closes the lkr get key && pbpaste extraction path.

Layer 3: Make exec the Default

Ultimately, not outputting secrets at all is the strongest guarantee. Building exec as the default workflow is the most important layer.


Key Classification: runtime vs admin

API keys are all “strings,” but they don’t all carry the same privileges. lkr makes this distinction explicit.

  • runtime: Keys for inference API calls (day-to-day use)
  • admin: Keys with elevated privileges such as usage/billing API access (kept separate)
$ lkr set openai:prod               # defaults to runtime
$ lkr set openai:admin --kind admin  # explicit admin
Enter fullscreen mode Exit fullscreen mode
  • list shows only runtime keys by default
  • gen and exec are both restricted to runtime keys

This constraint prevents “just inject everything” from becoming an easy habit.


gen Is a Fallback — Use It Only When Files Are Required

lkr gen is an escape hatch for tools that require a config file and can’t accept env vars.

$ lkr gen .env.example -o .env
  Resolved from Keychain:
    OPENAI_API_KEY       <- openai:prod
    ANTHROPIC_API_KEY    <- anthropic:main
  Kept as-is (no matching key):
    DATABASE_URL

  Generated: .env (2 resolved, 1 unresolved)
Enter fullscreen mode Exit fullscreen mode
  • Generated files are written with 0600 permissions
  • A warning is shown if the output file is not listed in .gitignore
  • admin keys are never resolved

For JSON templates, use explicit {{lkr:provider:label}} placeholders:

{
  "mcpServers": {
    "codex": {
      "env": {
        "OPENAI_API_KEY": "{{lkr:openai:prod}}"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Note: gen writes a file, which means any process with the same permissions can read it. Prefer exec whenever possible — gen is the last resort.


Threat Model: What It Protects and What It Doesn’t

Security tooling loses credibility when its scope is unclear. Here’s an honest breakdown.

In Scope

Threat Mitigation Feature
Plaintext keys sitting in .env Keys stored in Keychain set / get
Keys leaking via CLI args, history, or logs Prompt input — value never passed as argument set
Keys lingering in clipboard 30s auto-clear (SHA-256 match only) get
Raw key extraction from non-interactive environments TTY guard restricts stdout and clipboard get
High-privilege keys mixed into everyday workflows runtime / admin separation gen / exec
Secrets remaining in memory Zeroizing<String> zeroes memory on drop everywhere

Out of Scope

Threat Why It’s Not Covered
Compromised machine with root access Keychain can be unlocked within the same user session
Processes with the same permissions reading gen-generated files Writing a file means the exposure path exists
Access via IDE integrated terminals (pty) isatty returns true, TTY guard doesn’t apply
Child processes logging injected env vars Out of scope once keys leave exec

This is exactly why exec should be the default. Not outputting secrets is the strongest guarantee available.


Architecture

The codebase uses a Rust workspace, separating business logic (lkr-core) from the CLI binary (lkr-cli). The KeyStore trait abstracts the storage backend, allowing tests to swap in a MockStore. All secret values are held in Zeroizing<String>, which zeroes memory when the value drops out of scope.

Currently macOS-only (via security-framework for native Keychain access). The KeyStore abstraction is designed for future backend support — Linux libsecret and Windows Credential Manager are the planned targets.

See SECURITY.md for the full threat model.


Install

git clone https://github.com/yottayoshida/llm-key-ring.git
cd llm-key-ring
cargo install --path crates/lkr-cli
Enter fullscreen mode Exit fullscreen mode

Requires Rust 1.85+ and macOS (native Keychain dependency).


Summary

Common risk lkr solution
Plaintext keys in .env near the repository Stored in Keychain, retrieved only when needed
Keys leaking via args / history / logs set uses prompt input
Keys lingering in clipboard 30s auto-clear (hash-match only)
Extraction from non-interactive environments TTY guard restricts stdout and clipboard
High-privilege keys mixing into daily workflows runtime / admin separation
Secrets remaining in memory zeroize zeroes on drop

Plaintext .env files were standard practice for personal development. But as AI agents normalize local command execution, that standard practice has quietly become a risk surface.

lkr uses the macOS Keychain with no additional service dependencies, keeps the learning curve minimal, and pushes exec as the default workflow to ensure secrets don’t leave the process boundary.

Try it out:

  1. cargo install, then lkr set openai:prod to store your first key
  2. Replace python script.py with lkr exec -- python script.py
  3. Delete the .env file

https://github.com/yottayoshida/llm-key-ring

Top comments (0)