Share encrypted .env files across a small team — no server, no accounts, no plaintext in git.
shenv uses age end-to-end encryption: the plaintext .env
never leaves your machine, and only teammates whose public key is on the recipients list can
decrypt. The encrypted env.shenv blob is safe to commit or drop in S3/a Gist — storage never
sees your secrets.
Two machines, one shell: the prompt colour is who you are. Every secret, key and teammate
in the recording is invented — see demo/shenv.tape.
- Your private key lives in
~/.shenv/key.txt. Created once, used for every repo — like an SSH key. - Each repo has
recipients.shenv: teammates' public keys (one for encryption, one for verifying signatures). Public, so it's committed. env.shenvis the encrypted.env, encrypted for all recipients at once and signed by whoever sealed it. Committed / shared..envis plaintext. Stays local, auto-gitignored.
seal: .env ──sign with your key, encrypt for every recipient──► env.shenv (shared)
open: env.shenv ──decrypt, verify who signed it──► .env (local only)
First dev in a repo:
shenv init bob # register in this repo (creates your keypair on first use)
# ...put secrets in .env...
shenv seal # .env → env.shenv, then commit env.shenv + recipients.shenvRun
shenv initinside the repo — it registers you in this repo'srecipients.shenv. If you only want a keypair (no repo yet), useshenv keygen. Re-runninginitin another repo reuses your existing key.
A new teammate:
shenv keygen # once ever, on their machine
shenv whoami # prints their public key and signing key
# they send you both keys (they're public — Slack/mail is fine)You grant them access:
shenv add-member alice age1... <signing-key>
shenv seal # re-encrypt so alice is included; commit env.shenvNow alice can:
shenv open # env.shenv → .envOr skip the file entirely and inject secrets straight into a process:
shenv run -- npm start # secrets live only in npm's environment, no .env writtenChanging a secret doesn't need a plaintext .env either:
shenv edit # decrypt → your $EDITOR → re-seal; plaintext never touches the repo| Command | What it does |
|---|---|
shenv keygen |
Create your keypair — no repo needed (init does this implicitly too) |
shenv init [name] |
Register yourself in this repo, set up .gitignore (name defaults to your git/OS username; creates your keypair if missing) |
shenv whoami |
Print your public key and signing key |
shenv status |
Read-only overview: your identity, whether you're a member, the team, backend, and whether .env is in sync with env.shenv |
shenv add-member <name> <key> <signing-key> |
Add a teammate's public keys (then seal) |
shenv remove-member <name> |
Revoke a teammate's access (then seal — and rotate the secrets they knew) |
shenv seal [file] |
Encrypt .env (or file) → env.shenv for all members, signed with your key |
shenv open [file] [--force] |
Decrypt env.shenv → .env, verifying who sealed it; asks before clobbering local edits |
shenv edit |
Edit the secrets in $EDITOR and re-seal — the plaintext lives only in a locked-down temp file outside the repo, shredded afterwards |
shenv run -- <command> |
Run a command with secrets injected as env vars — no plaintext .env on disk |
shenv remember |
Cache your passphrase in the OS keychain so open stops asking |
shenv forget |
Remove the cached passphrase from the keychain |
shenv update [--check] |
Update to the latest release (checksum-verified); --check only reports what's out |
pushandpullstill work as deprecated aliases forsealandopen. They print a deprecation warning and will be removed in a future release.
shenv update # download the latest release, verify its SHA-256, swap in place
shenv update --check # just tell me if a newer version is outupdate pulls the archive for your platform from GitHub Releases and verifies it
twice before touching anything: the checksums file must carry a valid Ed25519
signature from the project's release key (the public key is embedded in the
binary; the private key never leaves the release pipeline's secret), and only
then does the archive's SHA-256 have to match it. An unsigned release, a
re-signed checksums file, or an old signed release replayed under a newer
version is refused outright. It also refuses to run on a dev/source build
(nothing to compare against) and on install locations you can't write to (it
tells you to reinstall or elevate).
shenv also shows a one-line "a newer version is available" hint on stderr at
most once a day. It never installs anything on its own — set
SHENV_NO_UPDATE_CHECK=1 to turn the hint off.
Maintainers: how the release key is generated, held, and rotated (without stranding already-shipped updaters) is documented in docs/release-signing.md.
go build -o shenv ./cmd/shenv
go test ./...cmd/shenv/ # entry point: arg dispatch + usage
cmd/release-sign/ # release tooling: sign/verify the checksums file (never shipped)
internal/
identity/ # the global ~/.shenv/key.txt keypair (+ optional passphrase)
recipients/ # the per-repo recipients.shenv list
crypto/ # age encrypt/decrypt (storage-agnostic)
keystore/ # optional OS-keychain passphrase cache
dotenv/ # minimal .env parser (for `run`)
backend/ # where the encrypted blob lives (file | exec)
update/ # signed self-update from GitHub Releases + "new version" notice
command/ # subcommands wiring the above together
By default the encrypted blob is a file (env.shenv) in the repo — share it by
committing it. For anything else, an exec backend delegates get/put to shell
commands, so any storage tool works without shenv depending on it. Configure it
in config.shenv (safe to commit — it holds no secrets):
backend = exec
# `get` writes the blob to stdout; `put` reads it from stdin.
get = aws s3 cp s3://my-bucket/env.shenv -
put = aws s3 cp - s3://my-bucket/env.shenvSwap in curl, rclone, gh gist, scp, … — whatever moves bytes. With no
config (or backend = file) it stays a repo file; set path = ... to relocate it.
Because the exec backend runs shell commands that arrive with the repo (a clone,
a merged PR), shenv treats them as untrusted: the first time it would run an
exec backend — and again whenever the commands change — it prints them and asks
for approval, recording your answer under ~/.shenv/trusted so later runs are
silent. Review the commands before approving; only approve what you'd be willing
to run yourself. In CI (no prompt), set SHENV_ALLOW_EXEC=1 to pre-approve, and
only where you control the config.
The private key in ~/.shenv/key.txt decrypts every secret you have access to, so
shenv protects it in layers:
- File permissions (always). On Unix the key is
0600; on Windows an explicit owner-only ACL is applied (the Unix bits are ignored there), so no other local user can read it. - Passphrase (optional).
shenv initoffers to encrypt the key at rest with a passphrase (age/scrypt). If set,openprompts for it;whoamistill works without it, since the public key is kept as a plaintext comment. - OS keychain (optional, for comfort).
shenv remembercaches the passphrase in the OS keychain (Windows Credential Manager, Linux Secret Service, macOS Keychain), bound to your login, soopenstops asking.shenv forgetremoves it. On systems without a keychain (headless Linux, containers)opensimply falls back to prompting — it never hard-fails. - Process hardening (always, best-effort). While shenv runs it disables core
dumps (and, on Linux, blocks other same-user processes from ptrace-attaching;
on Windows it excludes itself from Windows Error Reporting dumps), so a crash
can't spill the decrypted key or
.envplaintext to disk. It also zeroes those secret buffers as soon as it's done with them. Honest caveat: Go's garbage collector may already have copied the bytes, so zeroing shrinks the exposure window rather than guaranteeing erasure — and none of this stops malware or root running as you. A hardware-backed key remains the answer to that.
These layers compose: a synced/copied key.txt is useless without the passphrase,
and the cached passphrase is bound to your OS login. Note: no software measure
protects against malware running as you — once the key is unlocked it lives in
process memory. For that threat, use a hardware-backed key.
shenv assumes a small, mutually trusted team. A few highlights:
- Every blob is signed.
sealsigns the payload (Ed25519, sign-then-encrypt);open/runreject anything not signed by a current member and tell you who sealed it — so a hijacked bucket or gist can't plant a replacementenv.shenv. - No accidental lockouts. Each blob embeds the member list it was encrypted for;
sealrefuses to ship one that would drop a current member — or yourself — from access. - Recipient changes are surfaced.
recipients.shenvrides the same untrusted channel as the ciphertext, sosealshows added/removed keys and asks you to confirm before encrypting for a changed set. - Your private key is protected in layers — file permissions, an optional passphrase, and an optional OS-keychain cache. See Protecting your private key.
Full threat model — what signing does and doesn't cover, replays, and run's
process-environment exposure — is in docs/security.md.
The security design was reviewed by Claude (Fable 5) 🤖 — a sanity check, not a substitute for a professional audit. Found a hole? Please report it privately via GitHub's security advisories, not a public issue. Regular bugs and feature requests are welcome as issues.
github:userpublic-key lookup for onboarding- CI support via
SHENV_IDENTITY/SHENV_PASSPHRASEenvironment variables
