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 file | Keychain (direct or via app) |
|---|---|---|
| At rest in project tree | Plaintext | Not in the repo folder at all |
| Encrypted at rest | Only if FileVault is on, and only when powered off | Yes, as its own encrypted store |
| Can be committed to Git | Yes — the most common leak | No |
| Survives folder copy / zip / USB | Yes, secret travels with it | No |
| Accidental cloud sync | High risk in Desktop/Documents | Not synced unless explicitly marked |
| Ergonomics | Trivial: cat, any editor | Needs an app or the security CLI |
| Works in CI | Common (usually generated) | No — use the CI provider's secret store |
| Works in Docker | Yes, env_file | No, must be injected |
| Cross-platform | Yes | macOS / Apple ecosystem only |
| Team sharing | Dangerous by default | Per-device, or an explicit encrypted export |
| Rotation bookkeeping | Manual, scattered across files | Single entry per secret |
| Audit "what do I have?" | find and hope | Enumerable |
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 | .env | Keychain | Notes |
|---|---|---|---|
| Committed to Git | ✗ Fails | ✓ Holds | The 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 | ✓ Holds | Secret travels inside the file |
| Synced to iCloud via Desktop/Documents | ✗ Fails | ✓ Holds | Silent, and very common |
| Restored from an old backup | ✗ Leaks stale secrets | ~ | Time Machine keeps deleted files |
| Malware running as you | ✗ Fails | ✗ Mostly fails | Login keychain is unlocked all session |
| Colleague at your unlocked desk | ✗ Fails | ~ | Depends on lock state and item ACLs |
| Secret pasted into Slack or an LLM | ✗ | ✗ | Storage 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, oracme_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
psoutput 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:
| Environment | Where secrets live |
|---|---|
| Developer laptop | Local encrypted vault (Keychain-backed) |
| CI | CI provider's secret store, or OIDC with no stored secret |
| Production | Cloud 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:
- Any generated env file is gitignored, and ideally named distinctly (
.env.local,.env.generated) so it's obvious it's disposable. - Never
exportsecrets 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_fileis 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;
.envover Slack is how leaks happen. See how to share env files safely. - Mixed-OS team →
.envas 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.