Both end up in the same place: a value in process.env when your app boots. The difference is where the secret lives between runs — in OS-managed encrypted storage, or in a plaintext file sitting next to your code.

That difference decides which mistakes are possible. A .env file can be committed, zipped, synced to iCloud, or emailed. A Keychain item cannot, because it isn't a file in your project. In exchange, Keychain is harder to read from a script, absent in CI, and macOS-only.


1. Quick comparison

Dimension.env fileKeychain (direct or via app)
At rest in project treePlaintextNot in the repo folder at all
Encrypted at restOnly if FileVault is on, and only when powered offYes, as its own encrypted store
Can be committed to GitYes — the most common leakNo
Survives folder copy / zip / USBYes, secret travels with itNo
Accidental cloud syncHigh risk in Desktop/DocumentsNot synced unless explicitly marked
ErgonomicsTrivial: cat, any editorNeeds an app or the security CLI
Works in CICommon (usually generated)No — use the CI provider's secret store
Works in DockerYes, env_fileNo, must be injected
Cross-platformYesmacOS / Apple ecosystem only
Team sharingDangerous by defaultPer-device, or an explicit encrypted export
Rotation bookkeepingManual, scattered across filesSingle entry per secret
Audit "what do I have?"find and hopeEnumerable

Apple reference: Keychain Services.


2. What each one actually protects against

Security claims only mean something against a specific threat. Here's the honest mapping:

Threat.envKeychainNotes
Committed to Git✗ Fails✓ HoldsThe single most common real leak
Laptop stolen, powered off✓ (with FileVault)FileVault does the work in both cases
Folder copied to USB / zipped and shared✗ Fails✓ HoldsSecret travels inside the file
Synced to iCloud via Desktop/Documents✗ Fails✓ HoldsSilent, and very common
Restored from an old backup✗ Leaks stale secrets~Time Machine keeps deleted files
Malware running as you✗ Fails✗ Mostly failsLogin keychain is unlocked all session
Colleague at your unlocked desk✗ Fails~Depends on lock state and item ACLs
Secret pasted into Slack or an LLMStorage cannot fix this

Two rows deserve emphasis because they're where people over-trust Keychain:

Malware running as your user. Your login keychain unlocks automatically when you log in and stays unlocked for the whole session. That's why apps don't prompt you constantly — and it means a process running as you can request items. Per-item ACLs restrict which app may read an item without prompting, which helps against unrelated apps but not against something impersonating your workflow. Keychain is not a boundary against local code execution.

Backups. Deleting a .env doesn't remove it from Time Machine snapshots. A secret you "removed" months ago may still be restorable — one more reason revocation, not deletion, is what ends an exposure.


3. The ergonomics problem, honestly

.env wins on ergonomics and pretending otherwise is why people quietly go back to it.

# .env — zero ceremony
echo 'STRIPE_SECRET_KEY=sk_test_123' >> .env
cat .env
# Keychain — writable, readable, but nobody memorises this
security add-generic-password -a "$USER" -s STRIPE_SECRET_KEY -w 'sk_test_123'
security find-generic-password -s STRIPE_SECRET_KEY -w
security delete-generic-password -s STRIPE_SECRET_KEY

Note also that the second command puts the secret in your shell history and in the process list while it runs. Use -w with no value to be prompted interactively instead:

security add-generic-password -a "$USER" -s STRIPE_SECRET_KEY -w
# (prompts for the password without echoing)

If a secure workflow is slower than the insecure one, the insecure one wins on a deadline. This is the actual reason .env files persist despite everyone knowing better — not ignorance, friction.


4. Why raw Keychain doesn't scale past a handful of secrets

Ad-hoc security commands are write-once, understand-never. At forty secrets across twelve projects you hit real problems:

  • No project grouping. Item names become a naming convention you invented and will forget: was it STRIPE_KEY, stripe-secret, or acme_STRIPE_SECRET_KEY?
  • No rotation metadata. Nothing records when a value was last changed or who issued it.
  • Accidental logging. A secret passed as a CLI argument lands in shell history and is visible in ps output while the command runs.
  • Access groups are easy to get wrong, and silently. You find out when something prompts unexpectedly, or worse, doesn't.
  • No audit view. Answering "which of my keys are reused?" means scripting against security dump-keychain, which is unpleasant.

A developer vault fixes the layer above Keychain rather than replacing it: project grouping, one entry per secret, rotation labels, .env export, and a health audit that surfaces reuse. PassStore does this with AES-256-GCM vault encryption and Argon2id key wrapping, using the Keychain and Secure Enclave for unlock rather than as the primary store — see Security for the model.

Crucially, it also auto-locks. That's the one thing raw login keychain cannot do for you: re-lock while you stay logged in, so the readable window is minutes rather than your entire workday.


5. CI is where Keychain simply doesn't apply

This trips people up when they try to standardise on one mechanism. There is no Keychain on a Linux build runner, and even macOS runners give you a fresh ephemeral keychain per job.

Use your CI provider's secret store. That's the correct answer, not a compromise:

# GitHub Actions — secrets injected as env vars, never committed
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
        env:
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}

So the realistic architecture is three stores, not one:

EnvironmentWhere secrets live
Developer laptopLocal encrypted vault (Keychain-backed)
CICI provider's secret store, or OIDC with no stored secret
ProductionCloud secret manager, KMS, or the platform's env config

.env files are a transport between these, not a home. That reframing resolves most of the argument.


6. The hybrid pattern

Keep canonical values in the vault. Materialise them only for the moment a process needs them.

Inject at runtime, no file on disk:

# Read from Keychain into the child process's environment only
STRIPE_SECRET_KEY="$(security find-generic-password -s STRIPE_SECRET_KEY -w)" \
  npm run dev

Wrap it so it's shorter than typing .env:

# ~/.zshrc — ergonomics matter more than elegance here
secret() { security find-generic-password -s "$1" -w 2>/dev/null; }

# Usage: STRIPE_SECRET_KEY="$(secret STRIPE_SECRET_KEY)" npm run dev

If your tooling insists on a file, generate it, use it, delete it:

# Makefile
dev:
	@printf 'STRIPE_SECRET_KEY=%s\n' "$$(security find-generic-password -s STRIPE_SECRET_KEY -w)" > .env.local
	@trap 'rm -f .env.local' EXIT; npm run dev

Two rules make the hybrid safe:

  1. Any generated env file is gitignored, and ideally named distinctly (.env.local, .env.generated) so it's obvious it's disposable.
  2. Never export secrets in your shell profile. An exported variable is inherited by every process you launch from that terminal for the rest of the session — including that unrelated npm script you didn't audit. Prefer the per-command prefix form above, which scopes the variable to one child process.

That second point is the "wrong terminal" bug: you export a production key to debug something, forget, and three hours later a local test run talks to production.


7. When .env is genuinely the right call

Not everything needs a vault. .env is reasonable for:

  • Non-secret configuration. PORT, LOG_LEVEL, NODE_ENV, feature flags. These aren't secrets; treating them as such creates noise that devalues the real controls.
  • Test-mode keys with negligible blast radius. A sk_test_ key that can only touch a sandbox.
  • Docker Compose, where env_file is the idiomatic mechanism.
  • Teaching and demos — with fake values only.
  • Cross-platform teams where half the developers aren't on macOS. A macOS-only store can't be the team standard; it can still be your local standard.

The test isn't "is it a secret?" but "what happens if this line ends up in a public repo?" If the answer is "nothing", a file is fine. If the answer involves money, customer data, or a 2am page, it belongs in a vault.

Deeper treatment: is it safe to store secrets in .env files?


Which should I use?

  • Non-secret config.env, committed as .env.example, no vault needed.
  • Local test credentials, macOS, solo → a Keychain-backed vault, injected at runtime.
  • Production credentials → neither. A cloud secret manager with audit logs, injected by your platform. Production keys shouldn't be on a laptop at all.
  • Shared across a team → a cloud secret manager or a dedicated sharing flow. Keychain is per-device by design; .env over Slack is how leaks happen. See how to share env files safely.
  • Mixed-OS team.env as the documented interface, with each developer free to back it with whatever local store their OS offers.

Does iCloud sync my Keychain items?

Partly, and it's worth checking rather than assuming. iCloud Keychain syncs items explicitly marked as synchronizable. Items created by security add-generic-password are local-only by default, but an application can create synchronizable items, and passwords saved by Safari and similar are synced.

If "no secret leaves this Mac" is a hard requirement, verify per item rather than trusting the default — and prefer a vault that states its sync behaviour plainly. PassStore has no cloud sync and no account server at all, which makes the question moot: there is no backend for data to reach.