How many plaintext secrets are sitting in your projects folder?
Every project you have started or cloned in the last few years left a .env file behind. Some left three. They hold database passwords, API keys and webhook secrets in plaintext, and most of them belong to projects you no longer remember.
The files you forgot about
A .env file starts as a convenience. Then a project grows a .env.local for your machine, a .env.production for the night you debugged prod, and a copy of the Stripe test key you pasted from the previous project. Multiply that by every repository in your projects folder, including the client work from two years ago.
A .gitignore line keeps those files out of git. It does nothing for everything else that reads your disk as you:
- backups such as Time Machine, which copy dotfiles like any other
- cloud sync, if your projects folder lives inside a synced folder
- a dotfiles repository that grabbed more than you meant it to
grep -r, editor search, and any script you run in that folder- coding agents with file access, which I wrote about in Your coding agent can read your .env
I built envsec to keep secrets in the OS credential store instead: the macOS Keychain, the Secret Service on Linux, the Windows Credential Manager. Moving one project is a single envsec load. Moving fifty means first finding them, and that is what envsec rescue does. It shipped in 1.1.2.
Run the scan first: it changes nothing
$# Report only: reads files, changes nothing$envsec rescue ~/projectsWithout flags, rescue only reads. It doesn't touch the keychain, .gitignore or any file. The path defaults to the current directory. Here is the real output on a test folder with five projects:
◎ Scanning ~/projects…
▸ projects-api ~/projects/api
· .env 3 secrets → projects-api.dev ▲ committed to git
▸ blog ~/projects/blog
· .env 1 secret → blog.dev
▸ acme-api ~/projects/clients/acme/api
· .env 1 secret → acme-api.dev
▸ globex-api ~/projects/clients/globex/api
· .env 1 secret → globex-api.dev
▸ shop ~/projects/shop
· .env 3 secrets → shop.dev
· .env.local 1 secret → shop.dev
· .env.production 2 secrets → shop.prod
────────────────────────────────────────
▪ 11 secrets in 7 files · 5 projects · 6 contexts
◆ 1 value appears in more than one place:
STRIPE_SECRET_KEY ×2 projects-api.dev, shop.dev
▲ 1 file is committed to git: rotate its secrets, removing a file does not erase history
▲ 1 variable with a name envsec cannot store (left in place):
__INTERNAL_FLAG (~/projects/api/.env)
● Nothing changed. Next:
envsec rescue /Users/you/projects --import move the secrets into the keychain
envsec rescue /Users/you/projects --import --remove-plaintext …and delete the .env filesEach .env file is listed under its project, with the number of secrets in it and the context it would go to. Values are never printed, only names and counts.
What it looks for
Files
rescue picks up .env, .env.local, .env.<mode> and .env.<mode>.local. It skips the following:
- templates:
.env.example,.env.sample,.env.template,.env.dist,.env.defaultsand similar suffixes - directories that don't hold your secrets:
node_modules,.git,dist,build,vendor,.venv,.next,targetand a few more - symlinks, which are never followed
- anything deeper than 8 levels below the starting folder (change it with
--depth) - files written by
envsec env-file: their values already live in the keychain
A file larger than 1 MB is reported as unreadable and skipped, since nobody writes a dotenv file that size by hand.
Projects and contexts
A project is the nearest folder above the file with a .git, package.json, pyproject.toml, go.mod, Cargo.toml, Gemfile or another common marker. Its folder name becomes the project name. When two projects share a name, both get their parent folder as a prefix, which is why the output above shows acme-api and globex-api.
Each project gets one context per mode, named <project>.<mode>. .env and .env.local map to dev; .env.production maps to prod; .env.staging stays staging. Inside a mode, files are layered in the order Vite and Next.js use: .env < .env.local < .env.<mode> < .env.<mode>.local. That is why shop.dev counts 3 secrets, not 4: the DATABASE_URL in .env.local wins over the one in .env.
Names are computed from what that scan sees. Run --import with the same path you reviewed, or the prefixes can change.
What the report tells you
- Reused values. The same value in more than one place, such as one API key copied into several projects, is reported by variable name and context. Values shorter than 8 characters and plain numbers are ignored, so ports don't show up as duplicates.
- Files committed to git. For each file,
rescuerunsgit ls-files --error-unmatchin its folder. A file that git tracks today is flagged, because its secrets are in the history and need rotating. - Names envsec cannot store. Variable names map to keys like
loaddoes:API_TOKENbecomesapi.token. A name like__INTERNAL_FLAGwould produce empty key segments, so it is listed and left in place. - Empty variables are counted and ignored: there is nothing to secure.
Add --json (envsec --json rescue ~/projects) to get the same plan as JSON, with key names, file paths and a committed flag per file.
Move the secrets: --import
When the report looks right, add --import (or -i). Here it is on the shop project, where I had already stored a newer session.secret by hand:
$envsec rescue ~/projects/shop --import◎ Scanning ~/projects/shop…
▸ shop ~/projects/shop
· .env 3 secrets → shop.dev
· .env.local 1 secret → shop.dev
· .env.production 2 secrets → shop.prod
────────────────────────────────────────
▪ 5 secrets in 3 files · 1 project · 2 contexts
▲ 1 secret already in the keychain with a different value (kept; use --import --force to overwrite):
shop.dev session.secret
■ .env, .env.local, .env.production added to ~/projects/shop/.gitignore
✔ 4 secrets secured · 3 plaintext files left in place
Delete them once you are ready: envsec rescue /Users/you/projects/shop --remove-plaintext
▶ Next: envsec -c shop.dev run --inject "<your command>"For every secret of every context, one of three things happens:
- not in the keychain yet: it is stored
- already there with the same value: it counts as secured, so running
rescuetwice is safe - already there with a different value: it is a conflict, and the keychain value is kept
--force (-f) overwrites conflicts with the value from the files. I'd use it sparingly. It writes whatever the current scan sees, so a later run after you've removed .env.local would push the older value from .env over the one you imported. For a handful of conflicts, decide by hand:
$# Compare the two values yourself, then keep the right one$envsec -c shop.dev get session.secret$envsec -c shop.dev add session.secret--import also updates .gitignore. For each file inside a git repository that isn't already ignored, the file name is appended to the .gitignore at the top of that repository, under a header, skipping lines that are already there:
# Added by envsec rescue: secrets now live in the OS keychain
.env
.env.local
.env.productionPass --no-gitignore to skip that step. The plaintext files themselves stay where they are: --import never deletes anything.
Delete the plaintext: --remove-plaintext
$envsec rescue ~/projects/shop --remove-plaintext✔ 4 secrets secured · 2 plaintext files removed
▲ 1 file kept:
.env DATABASE_URL is overridden by .env.local
▶ Next: envsec -c shop.dev run --inject "<your command>"A file is deleted only when every value in it is in the keychain exactly as written. Right before deleting, rescue reads each value back from the keychain and compares it. Any file that fails a check is kept, with the reason printed:
- a value is not in the keychain yet, or differs from it
- a variable name cannot be stored
- a value is overridden by another file, like
.envabove: only the winningDATABASE_URLwas imported, so the older one would be lost - the file cannot be read
--remove-plaintext works on its own when the secrets are already imported, as in this second run, or together with --import in one go.
What rescue does not do
Knowing the edges matters more than the happy path:
- It doesn't touch git history. No rewrite, no
git rm, no commit. A deleted file that git tracked shows up as deleted ingit status, and you commit that yourself. The old commits still contain the secrets. - It doesn't search the history. The git check covers files tracked today. A
.envcommitted once and removed later is not flagged. - It doesn't rotate anything. A key that was committed or copied around is still valid until you revoke it with the provider.
- It doesn't reach copies. Backups, snapshots and synced copies made before today still contain the files. Deleting is a plain file removal, not a secure wipe.
- It only knows dotenv files. Secrets in
config.json, shell profiles or a tool's credentials file are out of scope. - It doesn't change how your app starts. Once the file is gone, the app needs the values from somewhere:
envsec -c shop.dev run --inject "npm run dev"passes them as environment variables. The full walkthrough is in From .env to the keychain in five minutes.
Flags
<path>: folder to scan recursively (default.)--import,-i: store the secrets in the keychain; without it,rescueonly reports--force,-f: with--import, overwrite keychain values that differ--remove-plaintext: delete each file whose values are all verified in the keychain--no-gitignore: don't append to.gitignore(it's on by default with--importor--remove-plaintext)--depth: how many folder levels to descend (default 8)--json: machine-readable output (a global flag)
Try it on your own folder
$# macOS / Linux (Homebrew), or: npm install -g envsec$brew tap davidnussio/homebrew-tap$brew install envsec$# Point it at the folder where your projects live$envsec rescue ~/projectsThe scan is read-only, so the worst case is a number you'd rather not have seen. If it finds files committed to git, rotate those keys first. Then import, check that your apps still start, and remove the plaintext. The command reference is in the docs, and the source is on GitHub.