Short answer: it depends on which secret, and the honest default is "acceptable for local test credentials, bad for anything valuable."

A .env file is plaintext on disk, in your project directory, one git add . away from a repository. That's not a flaw in the format — it's the format working as designed. The question is whether that's an acceptable place for the specific secret you're holding.

The one-line test: what happens if this line appears in a public GitHub repository tomorrow? If the answer is "nothing much", a file is fine. If the answer involves money, customer data, or a 2am page, it needs somewhere better.


Verdict by secret type

SecretPlaintext .env on a laptop?
PORT, LOG_LEVEL, NODE_ENV, feature flagsYes — not secrets at all
Local database password (localhost, throwaway data)Yes — negligible blast radius
Test/sandbox API key (sk_test_…)Acceptable — rotate if leaked, no real damage
Third-party dev key with a free tier and rate limitsAcceptable — annoying to leak, not dangerous
Live payment key (sk_live_…)No — direct financial loss
Production database credentialsNo — customer data
Cloud provider keys (AWS, GCP, Azure)No — crypto-mining bills land in hours
Signing keys, JWT secrets, encryption keysNo — enables forgery, not just access
Anything shared across a teamNo — needs access control and audit
OAuth client secrets for a live appNo — impersonation

If your .env currently contains anything from the bottom half, that's the finding. Fixing it beats every other item in this article.


1. What a .env file actually is

Worth being precise, because the informality is part of the risk.

A .env file is plaintext, readable by any process running as your user, and trivially copyablecp, zip, rsync, Time Machine, an IDE's "export project", a folder dragged into Slack.

It's also not a standard. There is no specification. Every dotenv implementation parses slightly differently: quoting rules, escaped newlines, variable interpolation, whether # starts a comment mid-line, whether trailing whitespace is stripped. Docker's --env-file notably does not handle quotes the way most language libraries do, which is why a value that works locally can arrive with literal " characters inside a container.

That inconsistency causes its own class of incident: a key that appears correct but has a trailing space or a stripped quote fails authentication, someone "fixes" it by pasting the value somewhere more visible, and now there are three copies. See how to copy environment variables without mistakes.

Two clarifications on what .env is not:

  • Not encrypted. FileVault encrypts the disk when the Mac is powered off. Once you log in, the file is plaintext to everything running as you. FileVault protects against a stolen laptop, not against a synced folder or a committed file.
  • Not hidden. The leading dot hides it from Finder and ls, and from nothing else. git add . includes it. tar, rsync, and backup tools include it. Only .gitignore excludes it from Git, and only if it's actually working.

2. Decision table

QuestionIf yesWhat to do
Could this file ever be committed or forked?High riskGitignore + a pre-commit scanner, template only
Is the repo inside iCloud Drive, Dropbox, or OneDrive?High riskMove the project out of synced folders
Is this a production secret on a laptop?High riskRemove it; use a cloud secret manager and break-glass access
Does the value get inlined into a client bundle?CriticalSee the frontend section below
Do CI logs ever print the environment?High riskMask the values; audit existing log retention
Is it a scoped test key, gitignored, local only?Lower riskReasonable — still rotate after any exposure
Do you need to prove who accessed it and when?.env cannotYou need an audited store
Do several people need the same value?.env is wrongUse a manager with sharing, not Slack

3. When .env is genuinely reasonable

All of these together, not any one alone:

  • The credential is test-mode or local-only, with low blast radius.
  • The file is gitignored, verified — not assumed:
    # Prints the matching rule, or exits non-zero if the file is NOT ignored
    git check-ignore -v .env
    
    # Catch a file that's already tracked despite .gitignore
    git ls-files --error-unmatch .env 2>/dev/null && echo "TRACKED — fix this"
    
  • The project is not inside a cloud-synced folder:
    # Common trap: Desktop and Documents sync to iCloud when that option is on
    pwd | rg -q '/(Desktop|Documents|Dropbox|OneDrive)/' && echo "SYNCED — move the project"
    
  • FileVault is on (fdesetup status) and your screen locks promptly.
  • A pre-commit scanner enforces the rule rather than your memory:
    brew install gitleaks && gitleaks protect --staged --redact
    
  • The secret is easy to rotate and you'd notice if it were abused.

Miss any of those and the risk is higher than you think it is.


4. Where it actually goes wrong

Ranked roughly by how often these cause real incidents.

Git commits. The dominant failure. Not because developers are careless, but because a plaintext secret one command away from a repository will eventually be committed by someone in a hurry. GitHub's secret scanning exists precisely because this happens constantly. I accidentally committed an API key.

Client bundles. In frontend frameworks, prefixed variables are inlined into JavaScript sent to the browser at build time. NEXT_PUBLIC_* in Next.js, VITE_* in Vite. The .env file was never the problem — the build published it:

# Check what actually shipped
rg -o 'sk_live_[A-Za-z0-9]+' .next/static -r 'FOUND A LIVE KEY IN THE BUNDLE'

This is the highest-severity .env mistake because the secret ends up on a CDN, cached, and possibly archived. How to prevent leaking API keys in frontend apps.

Backups keeping deleted files. Time Machine snapshots retain a .env after you delete it. Secrets you "cleaned up" months ago may still be restorable — which is why revocation, not deletion, is what ends an exposure.

Cloud sync. If "Desktop & Documents Folders" syncing is on and your code lives there, every .env has been uploaded. Silent, and extremely common.

Docker image layers. ARG and ENV at build time bake values into layers that persist in the image, including in public registries:

docker history --no-trunc myimage:latest | rg -i 'ARG|ENV'

CI logs. A build step that echoes the environment, or a test that dumps config on failure, writes secrets to logs that are often more widely readable and longer-lived than the code.

Error trackers. Sentry and similar capture request context and local variables. A secret in an env var can end up in a breadcrumb, in a third-party system, visible to everyone with dashboard access.

Shell session bleed. The set -a; source .env; set +a pattern exports every variable into your shell for the rest of the session, and every process you launch from that terminal inherits them. Load production values once to debug something and a local test run three hours later may talk to production. Scope variables to a single command instead:

# Inherited by exactly one child process
STRIPE_SECRET_KEY="$(secret STRIPE_SECRET_KEY)" npm run dev

Screen shares and screenshots. .env invites "open the whole file", which puts every secret on screen at once. A vault reveals one value at a time — a small design difference with real consequences during a pairing session.


5. A safer architecture that keeps env vars

The fix is not abandoning environment variables. Runtime config via process.env is correct — 12-factor is right about that. What changes is where the value rests between runs.

Storage (encrypted vault)  →  injection  →  process environment  →  your app
  1. Canonical values live in an encrypted store. On macOS, PassStore keeps them in an AES-256-GCM vault with Argon2id key wrapping, unlocked by Touch ID and re-locked on a timer, with no cloud sync (Security).
  2. Inject at process start, scoped to that process:
    STRIPE_SECRET_KEY="$(security find-generic-password -s STRIPE_SECRET_KEY -w)" \
      npm run dev
    
  3. If your tooling requires a file, generate it and 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
    
  4. Commit .env.example with blank values, so the file documents which variables exist without holding any.

The property you gain: no long-lived plaintext secret in the project tree. That single change removes the Git, sync, backup, and screenshot risks at once, because there's no file to catch them.


6. Compliance

Regulated environments generally expect encryption at rest, access control, and an audit trail showing who read what and when. A plaintext .env file provides none of the three, and no amount of .gitignore discipline changes that — the control is missing, not weak.

The usual answers: a cloud secret manager for production, SOPS or similar for encrypted config committed to Git, and short-lived credentials via OIDC so there's nothing durable to store. See open source secret managers compared.

A local vault helps with the developer laptop part of that scope by removing plaintext copies, but it does not produce a central audit log — there's no server, so there's nothing to log centrally. If an auditor needs access records, that's a cloud secret manager's job. Whether any specific setup satisfies a specific clause is a question for your compliance team, not for an engineering blog.


Is .env safe if I add it to .gitignore?

Safer, and not sufficient. .gitignore addresses exactly one leak path.

It does nothing about cloud sync, Time Machine, Docker layers, CI logs, error trackers, screenshots, or client bundles. It also has no effect on files Git is already tracking — the single most common reason people believe they're protected when they aren't:

git ls-files --error-unmatch .env 2>/dev/null && echo "still tracked despite .gitignore"

Treat .gitignore as necessary, not as the answer.


Is .env safe if my repository is private?

It reduces exposure; it doesn't remove it. Private repos are readable by every collaborator, including contractors and people who left; they get forked, and forks keep their own copies; CI has access; and repos change visibility — "we open-sourced it" has ended well for approximately nobody with live keys in history.

Private is a meaningful mitigation for a test key. It is not a reason to keep production credentials in a file.