All posts
6 min readDavid Nussio

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:

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

Terminal
# Report only: reads files, changes nothing
$envsec rescue ~/projects

Without 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:

text
◎ 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 files

Each .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:

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

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:

Terminal
$envsec rescue ~/projects/shop --import
text
◎ 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:

--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:

Terminal
# 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:

gitignore
# Added by envsec rescue: secrets now live in the OS keychain
.env
.env.local
.env.production

Pass --no-gitignore to skip that step. The plaintext files themselves stay where they are: --import never deletes anything.

Delete the plaintext: --remove-plaintext

Terminal
$envsec rescue ~/projects/shop --remove-plaintext
text
✔ 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:

--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:

Flags

Try it on your own folder

Terminal
# 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 ~/projects

The 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.